HotPDF Delphi Component libera cada objeto PDF que un documento posee cuando ese documento se cierra o se recarga: THotPDF.CloseIndirectObjects recorre el registro de objetos, junta cada arista de propiedad en un conjunto de punteros, desengancha todas esas aristas y recién entonces libera cada nodo único y cada payload de stream exactamente una vez. Ese orden de tres fases es lo que permite que hijos compartidos, ciclos de propiedad, registros duplicados y alias wrapper/cuerpo se desarmen sin 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 de stream perezosas del 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 en ese código también era sincero al respecto. Liberar los objetos uno por uno causaba access violations, así que el "enfoque seguro" era no liberarlos en absoluto. Este artículo trata de por qué el enfoque individual realmente crasheaba y de cómo se ve un teardown que sí funciona en un lenguaje con manejo manual de memoria
¿Por qué no se puede simplemente hacer Free de cada objeto registrado?
Porque los destructores de las clases de objeto no se ponen de acuerdo sobre quién es dueño de 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 entonces algo de memoria dos veces y otra parte nunca, según qué clases queden 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, bajo el supuesto de que los hijos indirectos pertenecen al registro y se van a liberar 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. Ahora piense en un registro que tiene un diccionario indirecto, un array que lista ese mismo diccionario en uno de sus slots y un wrapper cuyo cuerpo también está registrado como raíz aparte, que es exactamente lo que produce el parser con archivos reales. Libere el array primero y el diccionario ya no está cuando el registro llega a él. Libere el wrapper y el cuerpo, en cualquier orden, y la segunda llamada ejecuta un destructor sobre un puntero colgado. Libere solo el diccionario y cualquier hijo indirecto que haya salteado 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
¿Qué cuenta como arista de propiedad en un grafo de objetos PDF?
Una arista de propiedad es un puntero cuyo destino la fuente es responsable de destruir; una referencia es cualquier otra cosa, y el teardown tiene que 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 una 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 ISO 32000-1 §7.3.10 define una referencia indirecta: un nombre para un objeto que vive en otro lado, 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 links. El back-pointer FParent que mantienen 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 los dos se los deja en paz, y el comentario en el código lo dice en una línea: los links y los punteros al padre son referencias, no aristas de propiedad
¿Cómo funciona el teardown de tres fases?
La fase uno es una recolección en anchura. La rutina siembra una worklist con cada entrada de IndirectObjects y después, para cada nodo, agrega los destinos de las aristas de propiedad de ese nodo, salteando lo que ya vio. 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 capacidad. Nada en 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
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 recolectado
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 después seguir solo aristas de propiedad
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 seguros de ejecutar a los destructores: cada arista de propiedad se pone en nil antes de que corra cualquier destructor. Un wrapper recibe MarkAsFreed, que limpia FInternalObject y fija el flag que su destructor chequea primero. Un objeto stream tiene Dictionary y Stream asignados a nil. Cada ítem de diccionario tiene Item^.Value en nil y cada slot de array se sobrescribe con nil. Después de esta pasada el grafo no tiene aristas, así que cuando la fase tres llama a Free sobre cada nodo de Nodes y después sobre cada payload de Streams, cada destructor no encuentra nada en qué recursar y destruye solo lo suyo
// Fase dos: desenganchar cada arista de propiedad 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);
Mire lo que gana esa división. Un diccionario compartido por dos objetos stream se recolecta una vez, se desengancha de los dos y se libera una vez. Un ciclo en el que un array lista a su propio diccionario padre termina porque el conjunto de vistos rechaza la segunda visita. Un wrapper y su cuerpo registrados los dos como raíces son dos punteros distintos en el conjunto, así que los dos se liberan, y el destructor del wrapper ya no intenta liberar el cuerpo porque MarkAsFreed le sacó 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 está bien
¿Cómo distinguir una fuga de la retención del allocator?
Revisando si el contador de asignaciones vivas del memory manager se mueve junto con la carga de trabajo, y no solo su huella reservada. Un memory manager de Delphi deja rondando los bloques grandes ya liberados para reutilizarlos, así que un proceso que se queda en 400 MiB después de cerrar un documento no necesariamente tiene una fuga; un proceso cuyo contador de bloques vivos sube uno por página por corrida sí la tiene. La sonda que impulsó este fix era deliberadamente chica: un writer THotPDF que produce una sola página y después tres readers que la cargan. Una vez liberados los cuatro, el reporte de heap mostraba exactamente cuatro asignaciones vivas de 512 KiB, una por instancia, que es el payload de content stream que cada una poseía y nunca liberaba. Al escalar, el mismo patrón se volvía inconfundible. Correr dos veces el pipeline de renderizado en paralelo movía la cifra de bloques grandes asignados de 384 MiB a 640 MiB, un aumento proporcional a la cantidad de páginas que la retención del allocator no puede explicar. Después de la reescritura, el diagnóstico de una sola página reportaba cero bytes grandes asignados y cero reservados una vez que las instancias desaparecían. Si usted está cazando el mismo tipo de crecimiento en su propio proceso, el grafo de dependencias de objetos con bytes retenidos le dice qué objetos guardan la memoria mientras el documento está abierto; este artículo trata del comportamiento de liberación de esos objetos cuando el documento se cierra
Los umbrales de memoria dan pruebas de regresión frágiles, así que las pruebas que se publican cuentan llamadas al destructor en su lugar. Un fixture arma 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, después libera el documento y verifica una destrucción por objeto único: un payload, dos streams, dos diccionarios, un array, un wrapper, un número. Con el código viejo las tres pruebas de vida útil reportaban cero destrucciones, que es la declaración más directa posible de lo que significa "dejarlo para la salida del proceso"
¿Qué tiene que pasar antes de que el grafo se desarme?
Cualquier trabajo de fondo 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 descartarse, porque si no un hilo worker o una referencia cacheada lee memoria liberada. Por eso CloseIndirectObjects abre con CancelLoadedPagePrefetch y después invalida la caché de páginas renderizadas antes de tocar el registro. La ruta de recarga en LoadFromFile y LoadFromStream y el destructor del componente pasan los dos por ahí, así que el mismo orden aplica tanto si usted está reemplazando un documento como si está disponiendo de la instancia; las reglas para reutilizar un mismo THotPDF entre documentos se apoyan en esa garantía. Dos detalles de ese preámbulo aparecieron solo al correr las pruebas. Primero, el destructor ya dispuso de los frequency sketches detrás de las cachés de render y de display list para cuando cierra el grafo, así que la invalidación está protegida por un chequeo de que esos campos no sean nil en lugar de llamarse incondicionalmente. 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 teardown interno del documento viejo. El handler se guarda, se pone en nil alrededor de la llamada y se restaura en un finally, y la regresión de recarga verifica un conteo de notificaciones de cero después del segundo LoadFromStream. Un fix de memoria que cambia un contrato de eventos sin avisar es una regresión con mejor prensa, así que se lleva su propia aserción. Si usted corre el pipeline de renderizado en paralelo contra un documento y después lo recarga, el paso de cancelación es lo que evita que el pool de workers compita con el teardown
Reutilizar el patrón en su propio código Delphi
La técnica no es específica de PDF. Cualquier modelo de objetos en Delphi donde los destructores posean hijos de forma inconsistente, donde se pueda llegar al mismo hijo desde varios padres o donde convivan punteros hacia atrás y hacia adelante va a crashear o a fugar con un Free ingenuo por objeto. El fix tiene siempre la misma forma: decidir qué campos de puntero son de propiedad y cuáles son referencias, recolectar el cierre de las aristas de propiedad con un conjunto de punteros que tolere revisitas, cortar cada arista y recién ahí destruir la lista plana. El paso de cortar es el que la gente saltea, y es el que hace seguros de reutilizar a los destructores existentes en lugar de forzar una reescritura de cada clase del modelo. Igual vale la pena enumerar los límites sin vueltas. El conjunto de punteros usa la dirección del objeto como identidad, así que un objeto ya liberado cuya dirección se reutilizó en una asignación nueva sería indistinguible; el orden garantiza que ningún destructor corra durante la recolección, y eso 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 va a fugar 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 referenciado solo por un link y que nunca se registró no es alcanzable por este teardown; en HotPDF el parser garantiza el registro, pero un grafo armado a mano debe 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 cambios de API. HotPDF es una librería 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 HotPDF Delphi PDF