Artículo técnico

Liberar el grafo de objetos PDF una sola vez en Delphi

HotPDF Delphi Component libera todos los objetos PDF que posee un documento cuando ese documento se cierra o se recarga: THotPDF.CloseIndirectObjects recorre el registro de objetos, recoge cada arista propietaria en un conjunto de punteros, desconecta todas esas aristas, y solo entonces libera cada nodo único y cada payload de stream exactamente una vez. Ese orden en tres fases es lo que permite que hijos compartidos, ciclos de propiedad, registros duplicados y alias wrapper/cuerpo caigan todos sin un double free y sin dejar nada atrás. Antes de la v2.752.4 la misma rutina hacía algo mucho más simple y mucho peor: liberaba las fuentes lazy de streams de archivo, llamaba a Clear sobre la lista IndirectObjects, liberaba el contenedor de la lista, y dejaba cada objeto PDF real para que lo reclamara la salida del proceso. El comentario de aquel código era honesto al respecto. Liberar los objetos uno a uno causaba access violations, así que el «enfoque seguro» era no liberarlos en absoluto. Este artículo va de por qué el enfoque individual sí petaba de verdad, y de cómo es un teardown que funciona en un lenguaje con gestión manual de memoria

¿Por qué no puedes simplemente llamar a Free en cada objeto registrado?

Porque los destructores de las clases de objeto no se ponen de acuerdo sobre quién posee qué, y el registro contiene entradas en varios niveles de la misma cadena de propiedad. Recorrer la lista y llamar a Free sobre cada entrada libera por tanto algo de memoria dos veces y otra nunca, según qué clases acaben una al lado de la otra

Tres asimetrías en HPDFObjs.pas y HPDFDoc.pas crean el problema. THPDFDictionaryObject.Destroy recorre sus Items y libera un valor solo cuando IsIndirect es False, dando por hecho que los hijos indirectos pertenecen al registro y se liberarán ahí. THPDFArrayObject.Destroy no hace esa distinción y libera cada elemento que guarda. Y THPDFIndirectObject.Destroy, el wrapper que lleva un número de objeto, libera su cuerpo InternalObject. Imagina ahora un registro que contiene un diccionario indirecto, un array que lista ese mismo diccionario en una de sus posiciones, y un wrapper cuyo cuerpo también está registrado como raíz aparte, que es exactamente lo que produce el parser con archivos reales. Libera el array primero y el diccionario ya no existe cuando el registro llega a él. Libera el wrapper y el cuerpo, en cualquier orden, y la segunda llamada ejecuta un destructor sobre un puntero colgante. Libera solo el diccionario y cualquier hijo indirecto que se saltó se queda asignado para siempre. Ningún orden del registro arregla esto, porque el registro es una lista plana y la relación de propiedad es un grafo, y razonar sobre el grafo es la única salida

Por qué liberar cada entrada del registro de HotPDF petaba: THPDFDictionaryObject.Destroy se salta los hijos indirectos, mientras THPDFArrayObject.Destroy libera todo lo que guarda y THPDFIndirectObject.Destroy libera su cuerpo InternalObject, así que con un wrapper, un array y un diccionario compartido en una misma lista plana IndirectObjects hay memoria que muere dos veces y memoria que no muere nunca
Los destructores no se ponen de acuerdo sobre quién posee qué, y el registro guarda entradas en varios niveles de la misma cadena de propiedad, así que ningún orden de una lista plana convierte un Free ingenuo por objeto en un teardown correcto

¿Qué cuenta como arista propietaria en un grafo de objetos PDF?

Una arista propietaria es un puntero cuyo destino el origen tiene la responsabilidad de destruir; una referencia es cualquier otra cosa, y el teardown debe seguir las del primer tipo e ignorar las del segundo. En HotPDF eso da exactamente cuatro tipos de arista: los Items de un THPDFDictionaryObject, los Items de un THPDFArrayObject, el InternalObject detrás de un THPDFIndirectObject, y las dos mitades de un THPDFStreamObject, su Dictionary y su payload Stream. Los tipos de referencia importan igual, porque seguir uno convierte el recorrido del grafo en un bucle infinito o en un use-after-free. Un THPDFLink guarda un número de objeto y una generación, que es como la ISO 32000-1 §7.3.10 define una referencia indirecta: un nombre para un objeto que vive en otro sitio, no el objeto en sí. Resolver ese número a través del registro devuelve un nodo que alguna otra arista ya posee, así que CloseIndirectObjects nunca desreferencia los links. El back-pointer FParent que guardan diccionarios y arrays es la misma historia en la otra dirección; el padre ya posee al hijo, así que seguir el puntero hacia arriba solo volvería a visitar un nodo por el que el recorrido ya pasó. A ambos se los deja en paz, y el comentario del código lo dice en una línea: los links y los punteros al padre son referencias, no aristas de propiedad

Aristas propietarias frente a referencias en el grafo de objetos de HotPDF: los Items de DictionaryObject, los Items de ArrayObject, el InternalObject de IndirectObject y las dos mitades de un StreamObject se siguen y se desconectan, mientras que el número de objeto de un THPDFLink y el back-pointer FParent son nombres de objetos que viven en otro sitio, y CloseIndirectObjects nunca los desreferencia
Una arista propietaria es un puntero cuyo destino el origen tiene que destruir; seguir una referencia en su lugar convertiría el recorrido en anchura en un bucle infinito o un use-after-free, así que los links y los punteros al padre se dejan en paz

¿Cómo funciona el teardown en tres fases?

La fase uno es una recogida en anchura. La rutina siembra una worklist con cada entrada de IndirectObjects, y luego, para cada nodo, añade los destinos de las aristas propietarias de ese nodo, saltándose todo lo ya visto. El conjunto de vistos es un array de direccionamiento abierto de punteros crudos hasheado con HPDFFastCacheHashInt64 sobre el valor del puntero, con sondeo lineal y un GrowSeen que duplica cuando llega a la mitad de ocupación. Nada de esa estructura reserva memoria por nodo, lo que importa cuando un documento lleva unos cientos de miles de objetos. Los payloads de stream van a una lista Streams aparte porque son descendientes de TStream y no nodos THPDFObject, y se liberan en su propia pasada

El teardown en tres fases de CloseIndirectObjects en HotPDF: una recogida en anchura siembra la worklist desde IndirectObjects y sigue solo las aristas propietarias a través de un conjunto de vistos de direccionamiento abierto hasheado con HPDFFastCacheHashInt64, la fase dos desconecta cada arista con MarkAsFreed y asignación a nil, y la fase tres libera cada nodo y cada payload de stream exactamente una vez
Cortar las aristas antes de que corra ningún destructor es lo que hace seguro reutilizar los destructores existentes: cada uno se encuentra entonces sin nada en lo que recursar, así que hijos compartidos, ciclos y alias wrapper-cuerpo caen todos sin un 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;   // ya recogido
    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 uno: sembrar con el registro y seguir solo las aristas propietarias
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;

La fase dos es la parte que hace seguro ejecutar los destructores: cada arista propietaria se pone a nil antes de que se ejecute ningún destructor. Un wrapper recibe MarkAsFreed, que limpia FInternalObject y pone el flag que su destructor comprueba primero. Un objeto stream tiene Dictionary y Stream asignados a nil. Cada elemento de diccionario tiene Item^.Value limpiado y cada posición de array se sobrescribe con nil. Tras esta pasada el grafo no tiene aristas, así que cuando la fase tres llama a Free sobre cada nodo de Nodes y luego sobre cada payload de Streams, cada destructor se encuentra sin nada en lo que recursar y se destruye solo a sí mismo

// Fase dos: desconectar cada arista propietaria antes de liberar nada
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 tres: cada nodo y payload único se libera exactamente una vez
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);

Mira lo que compra esa separación. Un diccionario compartido por dos objetos stream se recoge una vez, se desconecta de ambos y se libera una vez. Un ciclo en el que un array lista a su propio diccionario padre termina porque el conjunto de vistos se niega a la segunda visita. Un wrapper y su cuerpo registrados ambos como raíces son dos punteros distintos en el conjunto, así que se liberan los dos, y el destructor del wrapper ya no intenta liberar el cuerpo porque MarkAsFreed ya se llevó esa arista. Un único TMemoryStream asignado como payload de dos objetos stream aparece en Streams exactamente una vez. Ninguno de esos casos necesita tratamiento especial, que es la señal de que el modelo es el correcto

¿Cómo distingues una fuga de la retención del allocator?

Comprobando si el contador de asignaciones vivas del memory manager se mueve con la carga de trabajo, y no solo su footprint reservado. Un memory manager de Delphi se guarda los bloques grandes liberados para reutilizarlos, así que un proceso que se queda en 400 MiB después de cerrar un documento no ha filtrado necesariamente; uno cuyo contador de bloques vivos sube de uno en uno por página y por ejecución, sí. La sonda que impulsó este fix era deliberadamente pequeña: un writer THotPDF produciendo una sola página, y luego tres readers cargándola. Después de liberar los cuatro, el informe del heap mostraba exactamente cuatro asignaciones vivas de 512 KiB, una por instancia, que es el payload del content stream que cada una poseía y nunca liberaba. Escalar el experimento hizo el mismo patrón inconfundible. Ejecutar dos veces el pipeline de render en paralelo movió la cifra de bloques grandes asignados de 384 MiB a 640 MiB, un aumento proporcional al número de páginas que la retención del allocator no puede explicar. Tras la reescritura, el diagnóstico de una sola página reportaba cero bytes grandes asignados y cero reservados una vez desaparecidas las instancias. Si estás cazando el mismo tipo de crecimiento en tu propio proceso, el grafo de dependencias de objetos con bytes retenidos te dice qué objetos retienen la memoria mientras el documento está abierto; este artículo va de su comportamiento de liberación cuando se cierra

Los umbrales de memoria producen tests de regresión frágiles, así que los tests que se publican cuentan llamadas a destructores en su lugar. Un fixture construye el grafo patológico a mano, con un diccionario compartido bajo dos streams, un array que contiene tanto el diccionario compartido como su propia raíz, un payload asignado a los dos streams, la raíz registrada dos veces, y un wrapper cuyo cuerpo está registrado por separado, y luego libera el documento y afirma una destrucción por objeto único: un payload, dos streams, dos diccionarios, un array, un wrapper, un número. Con el código viejo los tres tests de ciclo de vida reportaban cero destrucciones, que es la afirmación más directa posible de lo que significa «dejarlo para la salida del proceso»

¿Qué tiene que pasar antes de que el grafo caiga?

Cualquier trabajo en segundo plano que tome prestados objetos del grafo tiene que parar primero, y cualquier caché que guarde display lists o bitmaps compilados a partir de esos objetos tiene que soltarse, o un hilo worker o una referencia cacheada leerá memoria liberada. CloseIndirectObjects abre por tanto con CancelLoadedPagePrefetch, y después invalida la caché de páginas renderizadas antes de tocar el registro. El camino de recarga en LoadFromFile y LoadFromStream y el destructor del componente pasan ambos por ahí, así que el mismo orden aplica tanto si reemplazas un documento como si destruyes la instancia; las reglas para reutilizar un mismo THotPDF entre documentos se apoyan en esa garantía. Dos detalles de ese preámbulo solo salieron a la luz al correr los tests. Primero, el destructor ya ha desechado los bocetos de frecuencia que hay detrás de las cachés de render y de display list cuando cierra el grafo, así que la invalidación va protegida por que esos campos no sean nil en lugar de llamarse sin condición. Segundo, InvalidateRenderedPageCache es la rutina que dispara OnLoadedDocumentModified con un índice de página -1, y quien recarga un archivo no debería recibir una notificación de edición por el desmontaje interno del documento viejo. El handler se guarda, se pone a nil alrededor de la llamada, y se restaura en un finally, y la regresión de recarga afirma un contador de notificaciones de cero tras el segundo LoadFromStream. Un fix de memoria que cambia en silencio un contrato de eventos es una regresión con mejor prensa, así que se lleva su propia aserción. Si lanzas el pipeline de render en paralelo contra un documento y luego lo recargas, el paso de cancelación es lo que evita que el pool de workers compita con el teardown

Reutilizar el patrón en tu propio código Delphi

La técnica no es específica de PDF. Cualquier modelo de objetos Delphi en el que los destructores posean hijos de forma inconsistente, en el que se pueda llegar al mismo hijo desde varios padres, o en el que convivan punteros hacia atrás y hacia delante, petará o filtrará memoria con un Free ingenuo por objeto. El fix siempre tiene la misma forma: decidir qué campos de puntero son propietarios y cuáles son referencias, recoger el cierre de las aristas propietarias a través de un conjunto de punteros que tolere revisitas, cortar cada arista, y destruir después la lista plana. El paso de cortar es el que la gente se salta, y es el que hace seguro reutilizar los destructores existentes en lugar de forzar una reescritura de cada clase del modelo. Los límites merecen decirse sin rodeos, eso sí. El conjunto de punteros usa la dirección del objeto como identidad, así que un objeto ya liberado cuya dirección reutilizara una asignación nueva sería indistinguible; el orden garantiza que ningún destructor corre durante la recogida, que es lo que descarta ese caso. El recorrido solo ve los cuatro tipos de arista que conoce, así que una clase nueva que posea un hijo a través de un campo que el recorrido no inspecciona filtrará ese hijo hasta que se le enseñe al recorrido. Y como los links se resuelven a través del registro en lugar de seguirse, un objeto al que solo se referencie mediante un link y que nunca se registró no es alcanzable por este teardown; en HotPDF el parser garantiza el registro, pero un grafo construido a mano tiene que respetar la misma regla

Todo esto está dentro del componente, así que el efecto visible para una aplicación es simplemente que cerrar o recargar un documento devuelve su memoria, sin cambio alguno de API. HotPDF es una biblioteca PDF VCL nativa para Delphi y C++Builder con código fuente completo; la referencia de API y una build de prueba están en la página del componente PDF HotPDF para Delphi