HotPDF Delphi Component geeft bij het sluiten of herladen van een document elk PDF-object vrij dat het document bezit: THotPDF.CloseIndirectObjects loopt door het objectregister, verzamelt elke eigenaarsrelatie in een pointerset, koppelt al die relaties los, en geeft pas daarna elk uniek knooppunt en elke streampayload exact één keer vrij. Die driefasenvolgorde is wat gedeelde kinderen, eigenaarscycli, dubbele registraties en wrapper/body-aliassen allemaal laat vallen zonder dubbele free en zonder iets achter te laten. Vóór v2.752.4 deed dezelfde routine iets veel eenvoudigers en veel slechters: hij gaf de lazy file stream-sources vrij, riep Clear aan op de lijst IndirectObjects, gaf de lijstcontainer vrij, en liet elk werkelijk PDF-object voor het afsluiten van het proces liggen. Het commentaar in die code was er eerlijk over. Objecten individueel vrijgeven veroorzaakte access violations, dus de "veilige aanpak" was ze helemaal niet vrijgeven. Dit artikel gaat erover waarom de individuele aanpak echt crashte, en hoe een teardown die werkt eruitziet in een taal met handmatig geheugenbeheer
Waarom kun je niet gewoon elk geregistreerd object Free aanroepen?
Omdat de destructors van de objectklassen het oneens zijn over wie wat bezit, en het register entries bevat op meerdere niveaus van dezelfde eigenaarsketen. De lijst doorlopen en Free aanroepen op elke entry geeft daarom sommig geheugen twee keer vrij en sommig geheugen nooit, afhankelijk van welke klassen toevallig naast elkaar staan
Drie asymmetrieën in HPDFObjs.pas en HPDFDoc.pas veroorzaken het probleem. THPDFDictionaryObject.Destroy loopt door zijn Items en geeft een waarde alleen vrij wanneer IsIndirect False is, in de veronderstelling dat indirecte kinderen van het register zijn en daar worden vrijgegeven. THPDFArrayObject.Destroy maakt dat onderscheid niet en geeft elk item vrij dat hij vasthoudt. En THPDFIndirectObject.Destroy, de wrapper die een objectnummer draagt, geeft zijn body InternalObject vrij. Stel je nu een register voor met een indirecte dictionary, een array die diezelfde dictionary in een van zijn slots noemt, en een wrapper waarvan de body ook als aparte root is geregistreerd, precies wat de parser op echte bestanden produceert. Geef de array eerst vrij en de dictionary is weg voordat het register hem bereikt. Geef de wrapper en de body vrij, in welke volgorde dan ook, en de tweede aanroep laat een destructor op een dangling pointer los. Geef alleen de dictionary vrij en elk indirect kind dat hij oversloeg blijft voor altijd gealloceerd. Geen enkele ordening van het register lost dit op, want het register is een platte lijst en de eigenaarsrelatie is een graaf, en over die graaf redeneren is de enige uitweg
Wat telt als eigenaarsrelatie in een PDF-objectgraaf?
Een eigenaarsrelatie is een pointer waarvan de bron verantwoordelijk is voor het vrijgeven van het doel; een verwijzing is al het andere, en de teardown moet het eerste soort volgen en het tweede negeren. In HotPDF geeft dat precies vier soorten relaties: de Items van een THPDFDictionaryObject, de Items van een THPDFArrayObject, het InternalObject achter een THPDFIndirectObject, en beide helften van een THPDFStreamObject, zijn Dictionary en zijn Stream-payload. De verwijzingssoorten zijn net zo belangrijk, want er één volgen verandert een graafwandeling in een oneindige lus of een use-after-free. Een THPDFLink bevat een objectnummer en generatie, precies zoals ISO 32000-1 §7.3.10 een indirecte verwijzing definieert: een naam voor een object dat elders woont, niet het object zelf. Dat nummer via het register oplossen levert een knooppunt op dat een andere relatie al bezit, dus CloseIndirectObjects derefereert links nooit. De FParent-back-pointer die dictionaries en arrays bijhouden is hetzelfde verhaal in de andere richting; de parent bezit het kind al, dus de pointer omhoog volgen zou alleen een knooppunt herbezoeken waar de wandeling al langs kwam. Beide blijven met rust, en het commentaar in de source zegt het in één regel: links en parentpointers zijn verwijzingen, geen eigenaarsrelaties
Hoe werkt de teardown in drie fasen?
Fase één is een breadth-first verzameling. De routine zaait een werklijst met elke entry van IndirectObjects en voegt daarna voor elk knooppunt de doelen van de eigenaarsrelaties van dat knooppunt toe, waarbij alles wat al gezien is wordt overgeslagen. De seen-set is een open-addressing-array van ruwe pointers, gehasht met HPDFFastCacheHashInt64 over de pointerwaarde, met lineaire probing en een GrowSeen die verdubbelt wanneer hij halfvol raakt. Niets in die structuur alloceert per knooppunt, wat telt wanneer een document een paar honderdduizend objecten draagt. Streampayloads gaan naar een aparte lijst Streams, omdat het TStream-afstammelingen zijn en geen THPDFObject-knooppunten, en ze worden in hun eigen ronde vrijgegeven
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
if (SeenCount + 1) * 2 >= Length(Seen) then GrowSeen;
Slot := PointerSlot(Pointer(Value), Length(Seen));
while Seen[Slot] <> nil do
begin
if Seen[Slot] = Pointer(Value) then Exit; // al verzameld
Slot := (Slot + 1) and (Length(Seen) - 1);
end;
Seen[Slot] := Pointer(Value);
Inc(SeenCount);
if Payload then Streams.Add(Value) else Nodes.Add(Value);
end;
// Fase één: zaai met het register en volg daarna alleen eigenaarsrelaties
for I := 0 to IndirectObjects.Count - 1 do
Collect(TObject(IndirectObjects[I]), False);
I := 0;
while I < Nodes.Count do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
Collect(THPDFIndirectObject(Obj).InternalObject, False)
else if Obj is THPDFStreamObject then
begin
Collect(THPDFStreamObject(Obj).Dictionary, False);
Collect(THPDFStreamObject(Obj).Stream, True);
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
Collect(PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value, False)
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
Collect(TObject(THPDFArrayObject(Obj).Items[J]), False);
Inc(I);
end;
Fase twee is het deel dat de destructors veilig maakt om te draaien: elke eigenaarsrelatie wordt op nil gezet voordat er een destructor loopt. Een wrapper krijgt MarkAsFreed, dat FInternalObject wist en de flag zet die zijn destructor als eerste controleert. Van een streamobject worden Dictionary en Stream op nil gezet. Van elk dictionary-item wordt Item^.Value gewist en elke arrayslot wordt met nil overschreven. Na deze ronde heeft de graaf geen relaties meer, dus wanneer fase drie Free aanroept op elk knooppunt in Nodes en daarna elke payload in Streams, vindt elke destructor niets meer om in te recurseren en vernietigt hij alleen zichzelf
// Fase twee: koppel elke eigenaarsrelatie los voordat er iets wordt vrijgegeven
for I := 0 to Nodes.Count - 1 do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
THPDFIndirectObject(Obj).MarkAsFreed
else if Obj is THPDFStreamObject then
begin
THPDFStreamObject(Obj).Dictionary := nil;
THPDFStreamObject(Obj).Stream := nil;
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value := nil
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
THPDFArrayObject(Obj).Items[J] := nil;
end;
// Fase drie: elk uniek knooppunt en elke payload wordt exact één keer vrijgegeven
IndirectObjects.Clear;
for I := 0 to Nodes.Count - 1 do TObject(Nodes[I]).Free;
for I := 0 to Streams.Count - 1 do TObject(Streams[I]).Free;
FreeAndNil(IndirectObjects);
Kijk wat de splitsing oplevert. Een dictionary die door twee streamobjecten wordt gedeeld wordt één keer verzameld, van beide losgekoppeld en één keer vrijgegeven. Een cyclus waarin een array zijn eigen parent-dictionary noemt, eindigt omdat de seen-set het tweede bezoek weigert. Een wrapper en zijn body die beide als root zijn geregistreerd zijn twee verschillende pointers in de set, dus beide worden vrijgegeven, en de destructor van de wrapper probeert de body niet meer vrij te geven omdat MarkAsFreed die relatie al heeft weggenomen. Eén enkele TMemoryStream die als payload van twee streamobjecten is toegewezen staat exact één keer in Streams. Geen van die gevallen vraagt speciale behandeling, en dat is het teken dat het model klopt
Hoe onderscheid je een lek van wat de allocator vasthoudt?
Door te kijken of het aantal levende allocaties van de memory manager meebeweegt met de werklast, niet alleen zijn gereserveerde voetafdruk. Een Delphi memory manager houdt vrijgegeven grote blokken achter voor hergebruik, dus een proces dat na het sluiten van een document op 400 MiB blijft staan heeft niet per se een lek; een proces waarvan het aantal levende blokken per pagina per run met één stijgt wel. De probe die deze fix in gang zette was bewust klein: één THotPDF-writer die één pagina produceert, en dan drie readers die hem laden. Nadat alle vier waren vrijgegeven toonde het heaprapport precies vier levende allocaties van 512 KiB, één per instantie, en dat is de contentstreampayload die elk ervan bezat en nooit vrijgaf. Opschalen maakte hetzelfde patroon onmiskenbaar. De parallelle renderpipeline twee keer draaien verplaatste het cijfer voor gealloceerde grote blokken van 384 MiB naar 640 MiB, een stijging evenredig met het paginanummer die allocatorretentie niet kan verklaren. Na de herschrijving meldde de single-page-diagnostiek nul gealloceerde grote bytes en nul gereserveerde zodra de instanties weg waren. Als u in uw eigen proces op dezelfde soort groei jaagt, vertelt de object dependency graph met vastgehouden bytes welke objecten het geheugen vasthouden zolang het document open is; dit artikel gaat over hun gedrag bij het vrijgeven wanneer het document sluit
Geheugendrempels maken regressietests broos, dus de meegeleverde tests tellen in plaats daarvan destructoraanroepen. Een fixture bouwt de pathologische graaf met de hand op, met een gedeelde dictionary onder twee streams, een array die zowel de gedeelde dictionary als zijn eigen root bevat, één payload die aan beide streams is toegewezen, de root twee keer geregistreerd, en een wrapper waarvan de body apart is geregistreerd, geeft daarna het document vrij en stelt één destructie per uniek object vast: één payload, twee streams, twee dictionaries, één array, één wrapper, één nummer. Onder de oude code meldden alle drie de levensduurtests nul destructies, wat de meest directe formulering is van wat "laat het voor het afsluiten van het proces liggen" betekent
Wat moet er gebeuren voordat de graaf wordt afgebroken?
Elk achtergrondwerk dat objecten uit de graaf leent moet eerst stoppen, en elke cache die display lists of bitmaps bevat die uit die objecten zijn samengesteld moet worden weggegooid, anders leest een workerthread of een gecachte verwijzing vrijgegeven geheugen. CloseIndirectObjects opent daarom met CancelLoadedPagePrefetch en maakt daarna de gerenderde paginacache ongeldig voordat hij het register aanraakt. Het herlaadpad in LoadFromFile en LoadFromStream en de destructor van de component lopen er beide doorheen, dus dezelfde ordening geldt of u een document vervangt of de instantie opruimt; de regels voor het hergebruiken van één THotPDF over meerdere documenten leunen op die garantie. Twee details in die inleiding kwamen pas boven water door de tests te draaien. Ten eerste heeft de destructor de frequencysketches achter de render- en display-list-caches al opgeruimd tegen de tijd dat hij de graaf sluit, dus de invalidatie is bewaakt met een check of die velden niet nil zijn in plaats van onvoorwaardelijk aangeroepen. Ten tweede is InvalidateRenderedPageCache de routine die OnLoadedDocumentModified afvuurt met paginaindex -1, en een aanroeper die een bestand herlaadt hoort geen bewerkingsmelding te krijgen voor de interne teardown van het oude document. De handler wordt opgeslagen, rond de aanroep op nil gezet en in een finally hersteld, en de herlaadregressie stelt een meldingsteller van nul vast na de tweede LoadFromStream. Een geheugenfix die stilletjes een eventcontract verandert is een regressie met betere PR, dus die krijgt zijn eigen assertie. Als u de parallelle renderpipeline op een document draait en het daarna herlaadt, is de cancel stap wat de workerpool ervan weerhoudt de teardown voor te blijven
Het patroon hergebruiken in uw eigen Delphi-code
De techniek is niet specifiek voor PDF. Elk Delphi-objectmodel waarin destructors kinderen inconsistent bezitten, waarin hetzelfde kind vanaf meerdere parents bereikbaar is, of waarin back-pointers en forward pointers naast elkaar bestaan, crasht of lekt onder een naïeve Free per object. De fix heeft altijd dezelfde vorm: bepaal welke pointervelden eigenaars zijn en welke verwijzingen, verzamel de afsluiting van eigenaarsrelaties via een pointerset die herbezoeken tolereert, knip elke relatie door, en vernietig dan de platte lijst. De knipstap is degene die mensen overslaan, en het is degene die de bestaande destructors veilig hergebruikbaar maakt in plaats van een herschrijving van elke klasse in het model te forceren. De grenzen verdienen het wel ronduit te worden genoemd. De pointerset gebruikt het objectadres als identiteit, dus een object dat al is vrijgegeven en waarvan het adres door een nieuwe allocatie is hergebruikt zou niet te onderscheiden zijn; de ordening garandeert dat er tijdens het verzamelen geen destructor loopt, en dat sluit dat uit. De wandeling ziet alleen de vier soorten relaties die hij kent, dus een nieuwe klasse die een kind bezit via een veld dat de wandeling niet bekijkt laat dat kind lekken tot de wandeling erover is bijgebracht. En omdat links via het register worden opgelost in plaats van gevolgd, is een object dat alleen door een link wordt gerefereerd en nooit is geregistreerd helemaal niet bereikbaar voor deze teardown; in HotPDF garandeert de parser de registratie, maar een met de hand gebouwde graaf moet dezelfde regel respecteren
Dit alles zit binnenin de component, dus het zichtbare effect voor een applicatie is simpelweg dat het sluiten of herladen van een document zijn geheugen teruggeeft, zonder API-wijziging. HotPDF is een native VCL PDF-library voor Delphi en C++Builder met volledige source; de API-referentie en een trialbuild staan op de HotPDF Delphi PDF component-pagina