O funcție Delphi sau FPC care returnează o înregistrare nu primește un Result proaspăt, zerorizat, la fiecare apel. Acea variabilă Result ascunsă pornește zero exact o singură dată, iar nimic nu o rezetează automat la zero între apeluri, așa că golirea ei la intrare este propria sarcină a funcției. Faceți acea golire cu FillChar(Result, SizeOf(Result), 0), iar de la al doilea apel înainte, rutina suprascrie o referință vie de șir sau tablou dinamic, în loc să o elibereze, orfelinizând orice bloc de heap indica acea referință
Scenariul unde asta mușcă este banal. Un proces de lot deschide o stivă de PDF-uri terțe și parcurge fiecare adnotare de pe fiecare pagină, extrăgând textul comentariului într-un jurnal de audit. Nimic din acea buclă nu arată periculos: fiecare apel este o simplă funcție care returnează o simplă înregistrare, niciun pointer în vedere, nimic care să semene cu gestiunea manuală a memoriei deloc. Numărarea de referințe în interiorul unei înregistrări este o regulă simplă de contabilitate Object Pascal, nu o particularitate specifică unei anumite biblioteci, iar orice cod Delphi sau FPC care amestecă FillChar cu tipuri de înregistrare care poartă șiruri sau tablouri dinamice este expus la același defect
De ce scurge FillChar pe un rezultat de înregistrare șiruri?
FillChar scurge șiruri pentru că nu are nicio idee despre ce fel de date suprascrie. FillChar(X, Count, Value) funcționează pe orice variabilă deloc: preia un bloc netipat de Count octeți și ștampilează fiecare cu Value, iar acesta este întregul contract. Aceasta este exact ceea ce face FillChar rapid și cu scop general, pentru că nu inspectează niciodată tipul lui X și nu ramifică niciodată pe ce înseamnă octeții de bază. Un câmp UnicodeString sau WideString în interiorul unei înregistrări nu sunt caracterele în sine; este un pointer către un bloc de heap care poartă un contor de referință înaintea datelor de caracter. FillChar vede câteva bytes care se întâmplă să conțină o valoare de pointer și le suprascrie cu zero exact așa cum ar suprascrie un câmp Integer sau Double. Pointerul dispare, contorul de referință pe care ar fi trebuit să îl decrementeze mai întâi nu este niciodată atins, iar blocul spre care indica stă alocat fără nimic rămas care să îl refere
Cum urmărește compilatorul șirurile și tablourile dinamice în interiorul unei înregistrări
Object Pascal numește un tip gestionat atunci când compilatorul trebuie să ruleze cod suplimentar pentru a-l menține corect pe parcursul atribuirii și ieșirii din domeniu. Tipurile de șiruri lungi precum AnsiString, UnicodeString și WideString se califică, la fel și tablourile dinamice, interfețele și Variant-urile, împreună cu orice înregistrare sau tablou fix care conține unul din acestea ca un câmp. Pentru fiecare câmp gestionat, compilatorul emite discret contabilitatea care altfel ar fi tedioasă și ușor de greșit manual: incrementarea unui contor de referință la atribuire, decrementarea lui atunci când variabila deținătoare este suprascrisă sau iese din domeniu, și eliberarea blocului de bază odată ce acel contor ajunge la zero. Acel mecanism este motivul pentru care codul Pascal obișnuit nu alocă sau eliberează niciodată manual un string, și de ce atribuirea unui tablou dinamic altuia este o operație ieftină, sigură, în loc de o buclă de copiere manuală. System.Default și Finalize sunt cele două moduri documentate de a invoca la cerere aceeași logică de eliberare, și acestea sunt ceea ce ar trebui să apeleze codul de golire al unei înregistrări, în loc de o umplere brută de memorie
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;
De ce începe scurgerea abia la al doilea apel?
Primul apel dintr-o buclă este întotdeauna inofensiv, ceea ce este exact ceea ce face acest defect ușor de ratat la testare. O variabilă locală de tip înregistrare gestionată pornește zero, iar nimic nu o resetează automat la zero între o trecere de buclă și următoarea, așa că prima dată când o buclă atribuie valoarea de retur a unei funcții în acea variabilă, câmpul său Description sau ContentsText este încă nil. FillChar suprascrie nil cu zero, ceea ce nu schimbă nimic în ce privește contorul de referință, iar apelul revine arătând complet corect. Al doilea apel este diferit: aceeași variabilă locală ține deja orice a scris primul apel în ea, iar Result-ul noului apel este scris direct în acea aceeași stocare, în loc de memorie proaspătă, goală. FillChar de la vârful acelui al doilea apel zerorizează un câmp care nu mai este nil, iar tot ce urmează după acel tipar de octeți este silențios greșit de atunci înainte. Un test care apelează funcția o singură dată și inspectează rezultatul nu va vedea niciodată problema; doar o buclă, sau orice cale de cod care apelează funcția repetat față de aceeași destinație, o expune
O scurgere reală: adnotări, semne de carte și înregistrări de legătură
PDFiumPas a livrat exact acest defect înainte de versiunea 1.56.4, în trei funcții care fiecare returnează o înregistrare care poartă cel puțin un câmp gestionat: cititorul de adnotări la nivel de pagină returnează un TPdfAnnotation care poartă șirurile ContentsText și AuthorText, cititorul de semne de carte returnează un TBookmark care poartă un șir Title, iar cititorul de adnotări-legătură returnează un TLinkAnnotation care poartă un șir ActionPath și un tablou dinamic Points. Toate trei se deschideau cu aceeași formă arătată mai jos: golirea Result cu un FillChar brut, apoi umplerea câmpurilor unul câte unul din datele de pagină de bază. Parcurgerea fiecărei adnotări de pe o pagină una câte una, modul obișnuit de a construi o listă de audit sau un panou de revizuire, apela cititorul de adnotări într-o buclă și scurgea textul adnotării anterioare la fiecare trecere după prima; un PDF construit cu un număr neobișnuit de mare de adnotări care poartă text putea crește memoria unui proces de lungă durată atâta timp cât acel proces continua să ruleze. Soluția a atins o singură linie în fiecare funcție: înlocuirea FillChar(Result, SizeOf(Result), 0) cu Result := Default(TPdfAnnotation) a fost suficientă, pentru că atribuirea Default unei înregistrări gestionate rulează secvența obișnuită eliberare-apoi-golire a compilatorului, în loc de o umplere brută de memorie
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;
Același pericol în spatele unui parametru var
Cititorul de semne de carte arată o versiune mai subtilă a aceleiași probleme, pentru că înregistrarea care este golită cu FillChar nu este propriul Result al funcției, ci un parametru var un apel mai jos. SetBookmarkData preia ieșirea sa ca var Data: TBookmark și obișnuia să golească Data la începutul corpului său cu FillChar; GetBookmark, funcția publică care efectiv returnează un TBookmark, apelează SetBookmarkData și transmite propriul Result direct ca acel argument var. Un parametru var este transmis prin referință, așa că Data în interiorul SetBookmarkData și Result în interiorul GetBookmark sunt aceeași stocare sub două nume, iar orice risc de aliasing se aplică propriului Result al unei funcții se aplică la fel de direct oricărei rutine helper care îl primește prin referință. Revizuirea doar a funcțiilor care declară literal un tip de retur înregistrare ratează această formă; căutarea trebuie să urmărească și fiecare parametru var și out în care este redirecționat un Result
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;
Când este FillChar încă apelul corect?
FillChar este încă corect, și adesea puțin mai ieftin, pentru o înregistrare construită în întregime din ordinali, câmpuri în virgulă mobilă, tablouri de dimensiune fixă ale acestora, sau alte înregistrări simple făcute din aceleași, pentru că nu există nimic în ea pentru ca compilatorul să finalizeze. Propriul tip de dreptunghi al PDFiumPas este exact acel caz: TPdfRectangle conține patru câmpuri Double și nimic altceva, iar golirea unuia cu FillChar nu eliberează nimic pentru că nu există nimic cu numărare de referințe de eliberat. Verificarea care separă cele două cazuri este simplu de formulat: are vreun câmp al înregistrării, la orice adâncime de imbricare, tipul string, AnsiString, WideString, un tablou dinamic, o interfață, sau un Variant? O înregistrare poate arăta perfect numerică la nivelul superior și tot poate eșua acel test dacă unul din câmpurile ei este ea însăși o înregistrare care îngroapă un șir câteva straturi mai jos, așa că verificarea trebuie să urmărească înregistrările imbricate până la capăt, nu să se oprească la lista de câmpuri cea mai exterioară. Auditarea unui cod existent pentru acest tipar este mecanică, nu exhaustivă: căutați fiecare apel FillChar a cărui țintă este o variabilă de tip înregistrare, apoi verificați lista de câmpuri a acelei înregistrări față de lista de tipuri gestionate de mai sus. Propriul audit v1.56.4 al PDFiumPas a rulat exact acea căutare pe întreaga bibliotecă și a găsit această expunere într-o singură unitate; fiecare alt punct de apel FillChar deja golea o înregistrare numerică simplă, unde FillChar era, și rămâne, unealta corectă
Același comportament al compilatorului care face un Result reutilizat periculos aici conduce de asemenea o familie înrudită de dezacorduri Delphi-versus-FPC în altă parte a acestui cod; un articol complementar despre capcanele cross-compiler acoperă un caz unde FPC și Delphi nu sunt de acord exact despre când o temporară de rezultat de înregistrare este finalizată în interiorul unei singure expresii, un simptom diferit al aceluiași fapt de bază că Result-ul de înregistrare al unei funcții nu este întotdeauna stocarea proaspătă, privată, care pare a fi. Bucla de adnotări folosită ca exemplu de-a lungul acestui articol nu este nici ea ipotetică: este aceeași parcurgere pagină-cu-pagină pe care ați scrie-o în timp ce construiți un panou de revizuire a adnotărilor, ceea ce este exact forma de cod care a transformat un FillChar de o linie într-o scurgere lentă de memorie în primul rând
Nimic din toate acestea nu necesită schimbarea bibliotecilor sau urmărirea unui bug în codul compilat al altcuiva: este o proprietate a limbajului Object Pascal însuși, pe care fiecare dezvoltator Delphi și FPC lucrează cu ea zilnic, iar soluția este un singur apel de funcție odată ce știți să căutați asta. API-urile de adnotări, semne de carte și adnotări-legătură descrise aici sunt livrate ca parte a componentei PDFium pentru Delphi, C++Builder, și Lazarus/FPC, alături de restul suprafeței de citire, randare și adnotare PDF acoperite în altă parte pe acest blog