Egy Delphi- vagy FPC-függvény, amely egy rekordot ad vissza, nem kap friss, kinullázott Result-ot minden híváskor. Az a rejtett Result-változó pontosan egyszer indul nullán, és semmi nem nullázza vissza automatikusan a hívások között, így a hívások közötti törlés magának a függvénynek a feladata. Ha ezt a törlést FillChar(Result, SizeOf(Result), 0)-val végzed, akkor a második hívástól kezdve a rutin felülír egy élő sztring- vagy dinamikus-tömb-hivatkozást ahelyett hogy felszabadítaná azt, árvává téve bármelyik kupacblokkot, amire az a hivatkozás mutatott
A forgatókönyv, ahol ez harap, hétköznapi. Egy kötegelt folyamat megnyit egy köteg harmadik féltől származó PDF-et, és bejár minden annotációt minden oldalon, kihúzva a megjegyzésszöveget egy auditnaplóba. Semmi ebben a ciklusban nem néz ki veszélyesnek: minden hívás egy egyszerű függvény, amely egy egyszerű rekordot ad vissza, sehol egy pointer, semmi, ami kézi memóriakezelésre hasonlítana egyáltalán. A referenciaszámlálás egy rekordon belül egy egyszerű Object Pascal könyvelési szabály, nem egy bármely konkrét könyvtárra jellemző furcsaság, és bármely Delphi- vagy FPC-kódbázis, amely kevert FillChart olyan rekordtípusokkal, amelyek sztringeket vagy dinamikus tömböket hordoznak, ki van téve ugyanennek a hibának
Miért szivárogtat sztringeket a FillChar egy rekord-eredményen?
A FillChar azért szivárogtat sztringeket, mert fogalma sincs, milyen fajta adatot ír felül. A FillChar(X, Count, Value) bármely változón működik: egy típus nélküli, Count bájtos blokkot vesz, és mindegyiket lebélyegzi Value-val, és ez a teljes szerződés. Pontosan ez teszi a FillChart gyorssá és általános célúvá, mert soha nem vizsgálja meg X típusát, és soha nem ágazik el aszerint, mit jelentenek az alapul szolgáló bájtok. Egy UnicodeString vagy WideString mező egy rekordon belül nem maguk a karakterek; egy pointer egy kupacblokkra, amely a karakteradat előtt hordoz egy referenciaszámlálót. A FillChar néhány bájtot lát, amelyek véletlenül egy pointerértéket tartanak, és felülírja azokat nullával, pontosan úgy, ahogyan egy Integer vagy Double mezőt is felülírna. A pointer eltűnik, a referenciaszámláló, amit előbb csökkentenie kellett volna, soha nem érintődik, és a blokk, amire mutatott, allokálva marad, anélkül hogy bármi rá hivatkozna
Hogyan követi nyomon a fordító a sztringeket és dinamikus tömböket egy rekordon belül
Az Object Pascal kezeltnek nevez egy típust, amikor a fordítónak extra kódot kell futtatnia, hogy helyesen tartsa hozzárendelés és hatókör-kilépés közben. A hosszú sztringtípusok, mint az AnsiString, UnicodeString, és WideString, minősülnek, és a dinamikus tömbök, interfészek, és Variant-ok is, bármely rekorddal vagy fix tömbbel együtt, amely ezek egyikét tartalmazza mezőként. Minden kezelt mezőnél a fordító csendben kibocsátja azt a könyvelést, amely egyébként fárasztó és könnyen elrontható lenne kézzel: növel egy referenciaszámlálót hozzárendeléskor, csökkenti azt, amikor a tartó változó felülíródik vagy hatókörön kívülre kerül, és felszabadítja az alapul szolgáló blokkot, amint az a számláló nullát ér el. Ez a gépezet az oka annak, hogy a közönséges Pascal-kód soha nem allokál vagy szabadít fel kézzel egy string-et, és annak, hogy egy dinamikus tömb hozzárendelése egy másikhoz olcsó, biztonságos művelet, nem pedig egy kézi másolási ciklus. A System.Default és a Finalize a két dokumentált mód ugyanannak a felszabadítási logikának igény szerinti kiváltására, és ezeket kellene egy rekord törlőkódjának meghívnia egy nyers memóriakitöltés helyett
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;
Miért csak a második híváskor kezdődik a szivárgás?
Egy ciklus első hívása mindig ártalmatlan, ami pontosan az, ami miatt ezt a hibát könnyű elmulasztani teszteléskor. Egy kezelt rekordtípusú helyi változó nullán indul, és semmi nem nullázza vissza automatikusan egyik ciklusátfutásból a másikba, így amikor egy ciklus először rendel hozzá egy függvény visszatérési értékét abba a változóba, annak Description vagy ContentsText mezője még mindig nil. A FillChar felülírja a nil-t nullával, ami semmit nem változtat a referenciaszámláló szempontjából, és a hívás teljesen helyesnek látszóan tér vissza. A második hívás más: ugyanaz a helyi változó már tartja, amit az első hívás írt bele, és az új hívás Result-ja közvetlenül abba az ugyanazon tárolóba íródik, nem egy friss, üres memóriába. A FillChar annak a második hívásnak a tetején kinulláz egy mezőt, amely már nem nil, és attól kezdve minden downstream annak a bájtmintának csendben rossz. Egy teszt, amely egyszer hívja meg a függvényt, és megvizsgálja az eredményt, soha nem fogja látni a problémát; csak egy ciklus, vagy bármely kódútvonal, amely ismételten meghívja a függvényt ugyanazon cél ellen, leplezi le
Egy valódi szivárgás: annotációk, könyvjelzők, és linkrekordok
A PDFiumPas pontosan ezt a hibát szállította az 1.56.4-es verzió előtt, három függvényben, amelyek mindegyike egy rekordot ad vissza, amely legalább egy kezelt mezőt hordoz: az oldal-szintű annotáció-olvasó egy TPdfAnnotation-t ad vissza, amely ContentsText és AuthorText sztringeket hordoz, a könyvjelző-olvasó egy TBookmark-ot ad vissza, amely egy Title sztringet hordoz, és a link-annotáció-olvasó egy TLinkAnnotation-t ad vissza, amely egy ActionPath sztringet és egy Points dinamikus tömböt hordoz. Mindhárom ugyanazzal az alább látható alakkal kezdett: törölte a Result-ot egy nyers FillChar-ral, majd egyenként töltötte ki a mezőket az alapul szolgáló oldal-adatból. Egy oldal minden annotációjának egyenkénti bejárása, a szokásos mód egy audit-lista vagy felülvizsgálati panel felépítésére, egy ciklusban hívta meg az annotáció-olvasót, és minden átfutásnál az első után szivárogtatta az előző annotáció szövegét; egy PDF, amelyet szokatlanul sok szöveghordozó annotációval hoztak létre, egy hosszan futó folyamat memóriáját addig növelhette, ameddig az a folyamat futott. A javítás egy sort érintett minden függvényben: a FillChar(Result, SizeOf(Result), 0) lecserélése Result := Default(TPdfAnnotation)-ra elég volt, mert egy Default hozzárendelése egy kezelt rekordhoz a fordító szokásos felszabadítás-majd-törlés sorozatát futtatja egy nyers memóriakitöltés helyett
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;
Ugyanaz a veszély egy var paraméter mögött
A könyvjelző-olvasó ugyanennek a problémának egy finomabb változatát mutatja, mert a rekord, amelyet FillChar-ral törölnek, nem magának a függvénynek a Result-ja, hanem egy var paraméter egy hívással lejjebb. A SetBookmarkData kimenetét var Data: TBookmark-ként fogadja, és korábban FillChar-ral törölte a Data-t teste tetején; a GetBookmark, a nyilvános függvény, amely ténylegesen visszaad egy TBookmark-ot, meghívja a SetBookmarkData-t, és átadja saját Result-ját egyenesen mint azt a var argumentumot. Egy var paraméter referencia szerint kerül átadásra, így a Data a SetBookmarkData-n belül és a Result a GetBookmark-on belül ugyanaz a tároló két név alatt, és bármilyen aliasing-kockázat is vonatkozik egy függvény saját Result-jára, ugyanolyan közvetlenül vonatkozik bármely segédrutinra is, amely azt referencia szerint kapja. Csak azoknak a függvényeknek az áttekintése, amelyek szó szerint rekord-visszatérési típust deklarálnak, elmulasztja ezt az alakot; a keresésnek minden var és out paramétert is követnie kell, amelybe egy Result továbbítódik
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;
Mikor még mindig a FillChar a helyes hívás?
A FillChar még mindig helyes, és gyakran kissé olcsóbb, egy teljesen ordinálisokból, lebegőpontos mezőkből, ezek fix méretű tömbjeiből, vagy ugyanezekből épülő más egyszerű rekordokból álló rekordhoz, mert nincs benne semmi, amit a fordítónak véglegesítenie kellene. A PDFiumPas saját téglalaptípusa pontosan ez az eset: a TPdfRectangle négy Double mezőt hordoz, és semmi mást, és egy ilyen FillChar-ral törlése semmit nem szabadít fel, mert nincs semmi referenciaszámlált, amit fel kellene szabadítani. Az ellenőrzés, amely elválasztja a két esetet, egyszerűen megfogalmazható: bármely mezője a rekordnak, bármely beágyazási mélységben, string, AnsiString, WideString, egy dinamikus tömb, egy interfész, vagy egy Variant típusú-e? Egy rekord tökéletesen numerikusnak nézhet ki a legfelső szinten, és mégis elbukhatja azt a tesztet, ha egyik mezője maga is egy rekord, amely néhány réteggel lejjebb egy sztringet temet el, így az ellenőrzésnek egészen végig kell követnie a beágyazott rekordokat, ahelyett hogy a legkülső mezőlistánál megállna. Egy meglévő kódbázis auditálása erre a mintára mechanikus, nem kimerítő: keress minden FillChar hívást, amelynek célja egy rekordváltozó, majd ellenőrizd annak a rekordnak a mezőlistáját a fenti kezelt-típus listával szemben. A PDFiumPas saját v1.56.4-es auditja pontosan ezt a keresést futtatta a teljes könyvtáron át, és egyetlen unitban találta ezt a kitettséget; minden más FillChar híváshely már egy egyszerű numerikus rekordot törölt, ahol a FillChar volt, és marad, a helyes eszköz
Ugyanaz a fordítói viselkedés, amely veszélyessé teszi itt egy újrahasznosított Result-ot, a Delphi-kontra-FPC nézeteltérések egy rokon családját is hajtja e kódbázis máshol; a cross-compiler buktatókról szóló kísérőcikk tárgyal egy esetet, ahol az FPC és a Delphi nem ért egyet abban, pontosan mikor véglegesítődik egy rekord-eredmény ideiglenes egyetlen kifejezésen belül, ugyanannak az alapul szolgáló ténynek egy másik tünete, hogy egy függvény rekord-Result-ja nem mindig az a friss, privát tároló, aminek látszik. Az annotáció-ciklus, amelyet e cikk átfutó példaként használ, sem hipotetikus: ugyanaz az oldalankénti bejárás, amit akkor írnál, amikor egy annotáció-felülvizsgálati panelt építesz, ami pontosan az a kódalak, amely egy egysoros FillChart lassú memóriaszivárgássá tett eleve
Ebből semmi nem igényel könyvtárváltást, vagy hibavadászatot valaki más lefordított kódjában: ez magának az Object Pascal nyelvnek egy tulajdonsága, amellyel minden Delphi- és FPC-fejlesztő naponta dolgozik, és a javítás egyetlen függvényhívás, amint tudod, mit keress. Az itt leírt annotáció-, könyvjelző-, és link-annotáció-API-k a Delphihez, C++Builderhez, és Lazarus/FPC-hez készült PDFium Komponens részeként érkeznek, a PDF-olvasási, -renderelési, és -annotációs felület többi részével együtt, amit ez a blog máshol tárgyal