Technical Article

Freeing a PDF Object Graph Exactly Once in Delphi: HotPDF

HotPDF Delphi Component releases every PDF object a document owns when that document closes or reloads: THotPDF.CloseIndirectObjects walks the object registry, collects each owning edge into a pointer set, detaches all of those edges, and only then frees each unique node and each stream payload exactly once. That three-phase order is what lets shared children, ownership cycles, duplicate registrations and wrapper/body aliases all come down without a double free and without leaving anything behind. Before v2.752.4 the same routine did something much simpler and much worse: it released the lazy file stream sources, called Clear on the IndirectObjects list, freed the list container, and left every actual PDF object for the process exit to reclaim. The comment in that code was honest about it, too. Freeing the objects individually caused access violations, so the "safe approach" was not to free them at all. This post is about why the individual approach really did crash, and what a teardown that works looks like in a language with manual memory management

Why can you not just Free every registered object?

Because the destructors of the object classes disagree about who owns what, and the registry contains entries at several levels of the same ownership chain. Walking the list and calling Free on each entry therefore frees some memory twice and some memory never, depending on which classes happen to sit next to each other

Three asymmetries in HPDFObjs.pas and HPDFDoc.pas create the problem. THPDFDictionaryObject.Destroy walks its Items and frees a value only when IsIndirect is False, on the assumption that indirect children belong to the registry and will be freed there. THPDFArrayObject.Destroy makes no such distinction and frees every item it holds. And THPDFIndirectObject.Destroy, the wrapper that carries an object number, frees its InternalObject body. Now consider a registry that holds an indirect dictionary, an array that lists that same dictionary in one of its slots, and a wrapper whose body is also registered as a separate root, which is exactly what the parser produces on real files. Free the array first and the dictionary is gone before the registry reaches it. Free the wrapper and the body, in either order, and the second call runs a destructor on a dangling pointer. Free the dictionary alone and any indirect child it skipped stays allocated forever. No ordering of the registry fixes this, because the registry is a flat list and the ownership relation is a graph, and reasoning about the graph is the only way out

Why freeing every HotPDF registry entry crashed: THPDFDictionaryObject.Destroy skips indirect children while THPDFArrayObject.Destroy frees everything it holds and THPDFIndirectObject.Destroy frees its InternalObject body, so with a wrapper, an array and a shared dictionary in one flat IndirectObjects list some memory dies twice and some never
The destructors disagree about who owns what, and the registry holds entries at several levels of the same ownership chain, so no ordering of a flat list can turn a naive per-object Free into a correct teardown

What counts as an owning edge in a PDF object graph?

An owning edge is a pointer whose target the source is responsible for destroying; a reference is anything else, and the teardown must follow the first kind and ignore the second. In HotPDF that gives exactly four edge kinds: the Items of a THPDFDictionaryObject, the Items of a THPDFArrayObject, the InternalObject behind a THPDFIndirectObject, and both halves of a THPDFStreamObject, its Dictionary and its Stream payload. The reference kinds matter just as much, because following one turns a graph walk into an infinite loop or a use-after-free. A THPDFLink holds an object number and generation, which is how ISO 32000-1 §7.3.10 defines an indirect reference: a name for an object that lives elsewhere, not the object itself. Resolving that number through the registry yields a node that some other edge already owns, so CloseIndirectObjects never dereferences links at all. The FParent back-pointer that dictionaries and arrays keep is the same story in the other direction; the parent already owns the child, so following the pointer upward would only revisit a node the walk has been through. Both are left alone, and the comment in the source says so in one line: links and parent pointers are references, not ownership edges

Owning edges versus references in the HotPDF object graph: DictionaryObject Items, ArrayObject Items, IndirectObject InternalObject and both halves of a StreamObject are followed and detached, while a THPDFLink object number and the FParent back-pointer are names for objects that live elsewhere, so CloseIndirectObjects never dereferences them
An owning edge is a pointer whose target the source must destroy; following a reference instead would turn the breadth-first walk into an infinite loop or a use-after-free, so links and parent pointers are left alone

How does the three-phase teardown work?

Phase one is a breadth-first collection. The routine seeds a worklist with every entry of IndirectObjects, then for each node it appends the targets of that node's owning edges, skipping anything already seen. The seen-set is an open-addressing array of raw pointers hashed with HPDFFastCacheHashInt64 over the pointer value, with linear probing and a doubling GrowSeen when it reaches half full. Nothing in that structure allocates per node, which matters when a document carries a few hundred thousand objects. Stream payloads go into a separate Streams list because they are TStream descendants rather than THPDFObject nodes and are freed on their own pass

The three-phase CloseIndirectObjects teardown in HotPDF: a breadth-first collect seeds the worklist from IndirectObjects and follows only owning edges through an open-addressed seen-set hashed with HPDFFastCacheHashInt64, phase two detaches every edge with MarkAsFreed and nil assignment, and phase three frees each node and stream payload exactly once
Cutting the edges before any destructor runs is what makes the existing destructors safe to reuse: each one then finds nothing to recurse into, so shared children, cycles and wrapper-body aliases all come down without a double free
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;   // already collected
    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;

// Phase one: seed with the registry, then follow only owning edges
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;

Phase two is the part that makes the destructors safe to run: every owning edge is set to nil before any destructor executes. A wrapper gets MarkAsFreed, which clears FInternalObject and sets the flag its destructor checks first. A stream object has Dictionary and Stream assigned nil. Each dictionary item has Item^.Value cleared and each array slot is overwritten with nil. After this pass the graph has no edges left, so when phase three calls Free on every node in Nodes and then every payload in Streams, each destructor finds nothing to recurse into and destroys only itself

// Phase two: detach every owning edge before freeing anything
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;

// Phase three: each unique node and payload is freed exactly once
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);

Look at what the split buys. A dictionary shared by two stream objects is collected once, detached from both, and freed once. A cycle where an array lists its own parent dictionary terminates because the seen-set refuses the second visit. A wrapper and its body both registered as roots are two distinct pointers in the set, so both are freed, and the wrapper's destructor no longer tries to free the body because MarkAsFreed already took that edge away. A single TMemoryStream assigned as the payload of two stream objects sits in Streams exactly once. None of those cases needs special handling, which is the sign the model is right

How do you tell a leak from allocator retention?

By checking whether the memory manager's live allocation count moves with the workload, not just its reserved footprint. A Delphi memory manager keeps freed large blocks around for reuse, so a process that stays at 400 MiB after closing a document has not necessarily leaked; a process whose live block count climbs by one per page per run has. The probe that drove this fix was deliberately small: one THotPDF writer producing a single page, then three readers loading it. After all four were freed, the heap report showed exactly four live 512 KiB allocations, one per instance, which is the content stream payload each one owned and never released. Scaling up made the same pattern unmistakable. Running the parallel render pipeline twice moved the large-block allocated figure from 384 MiB to 640 MiB, an increase proportional to page count that allocator retention cannot explain. After the rewrite, the single-page diagnostic reported zero large bytes allocated and zero reserved once the instances were gone. If you are hunting the same kind of growth in your own process, the object dependency graph with retained bytes tells you which objects hold the memory while the document is open; this post is about their release behavior when it closes

Memory thresholds make brittle regression tests, so the shipped tests count destructor calls instead. A fixture builds the pathological graph by hand, with a shared dictionary under two streams, an array containing both the shared dictionary and its own root, one payload assigned to both streams, the root registered twice, and a wrapper whose body is separately registered, then frees the document and asserts one destruction per unique object: one payload, two streams, two dictionaries, one array, one wrapper, one number. Under the old code all three lifetime tests reported zero destructions, which is the most direct possible statement of what "leave it for process exit" means

What must happen before the graph comes down?

Any background work that borrows objects from the graph has to stop first, and any cache that holds display lists or bitmaps compiled from those objects has to be dropped, otherwise a worker thread or a cached reference reads freed memory. CloseIndirectObjects therefore opens with CancelLoadedPagePrefetch, then invalidates the rendered page cache before it touches the registry. The reload path in LoadFromFile and LoadFromStream and the component destructor both route through it, so the same ordering applies whether you are replacing a document or disposing of the instance; the rules for reusing one THotPDF across documents lean on that guarantee. Two details in that preamble only surfaced from running the tests. First, the destructor has already disposed of the frequency sketches behind the render and display list caches by the time it closes the graph, so the invalidation is guarded on those fields being non-nil rather than called unconditionally. Second, InvalidateRenderedPageCache is the routine that fires OnLoadedDocumentModified with a page index of -1, and a caller who reloads a file should not receive an edit notification for the old document's internal teardown. The handler is saved, set to nil around the call, and restored in a finally, and the reload regression asserts a notification count of zero after the second LoadFromStream. A memory fix that quietly changes an event contract is a regression with better PR, so it gets its own assertion. If you run the parallel render pipeline against a document and then reload it, the cancel step is what keeps the worker pool from racing the teardown

Reusing the pattern in your own Delphi code

The technique is not specific to PDF. Any Delphi object model where destructors own children inconsistently, where the same child can be reached from several parents, or where back-pointers and forward pointers coexist, will crash or leak under a naive per-object Free. The fix is always the same shape: decide which pointer fields are owning and which are references, collect the closure of owning edges through a pointer set that tolerates revisits, cut every edge, then destroy the flat list. The cutting step is the one people skip, and it is the one that makes the existing destructors safe to reuse instead of forcing a rewrite of every class in the model. The boundaries are worth stating plainly, though. The pointer set uses the object address as identity, so an object that has already been freed and whose address was reused by a fresh allocation would be indistinguishable; the ordering guarantees that no destructor runs during collection, which is what rules that out. The walk only sees the four edge kinds it knows about, so a new class that owns a child through a field the walk does not inspect will leak that child until the walk is taught about it. And because links are resolved through the registry rather than followed, an object that is referenced only by a link and was never registered is not reachable by this teardown at all; in HotPDF the parser guarantees registration, but a hand-built graph must respect the same rule

All of this is inside the component, so the visible effect for an application is simply that closing or reloading a document returns its memory, with no API change. HotPDF is a native VCL PDF library for Delphi and C++Builder with full source; the API reference and a trial build are on the HotPDF Delphi PDF component page