Artículo técnico

Presupuesto de recursos del parser PDF con PDFium Component

PDFium Component expone TPdfParserResourceBudget, un único record que acota la profundidad de recursión, el recuento de tokens, el de objetos, los bytes decodificados, la ratio de expansión, la asignación y un plazo de tiempo real para cada parser de PDF de la librería. ConfigurePdfParserResourceBudget lo fija atómicamente para el proceso, y cada parse copia una instantánea para que sus umbrales no puedan moverse a mitad de operación

Seis parsers, seis techos privados

La situación que forzó esto era mundana y probablemente familiar. El recorrido sparse del xref tenía su propio techo de objetos. El inspector de streams tenía su propio tope de bytes decodificados. XMP tenía uno, XFDF y FDF otro, la normalización de contenido un tercero, y los valores por defecto de preflight un cuarto. Cada uno de esos números era defendible por separado y ninguno era alcanzable desde fuera, así que un llamador que quisiera decir «este camino de ingesta trata con subidas anónimas, nada de esto puede asignar más de 64 MiB ni ejecutarse más de cuatro segundos» no tenía dónde decirlo. El segundo problema era peor porque era silencioso: donde un límite sí vivía en un global mutable, un parse que empezaba bajo una política podía terminar bajo otra. Un thread llama a un setter mientras otro va por la mitad de un object stream, y la primera mitad de ese recorrido está acotada por el techo viejo mientras que la segunda lo está por el nuevo. Nada crashea, nada se loguea, y después no puedes decir bajo qué límites se ejecutó de verdad la operación, que es exactamente la propiedad para la que querías los límites

¿Qué acota de verdad TPdfParserResourceBudget?

Siete dimensiones, elegidas porque cada una atrapa una clase de entrada que las demás dejan pasar. MaxRecursionDepth por defecto 1024 y evita que los diccionarios y arrays anidados conviertan la sintaxis de objetos de ISO 32000-1 §7.3 en un desbordamiento de pila. MaxTokens por defecto 10.000.000 y acota el trabajo de lexing independientemente del tamaño del archivo. MaxObjects por defecto 4.000.000 y cubre los recuentos de subsecciones del xref, los valores /N de object streams y los recorridos de grafos. MaxDecodedBytes por defecto 256 MiB y MaxAllocationBytes 512 MiB, así que un archivo puede rechazarse antes de la asignación en lugar de después. MaxExpansionRatio por defecto 1000.0 y es la que la gente subestima: los filtros de stream de ISO 32000-1 §7.4 son compresión de propósito general, unos pocos kilobytes de datos Flate pueden expandirse legítimamente en cientos de megabytes, y un techo de ratio es la defensa estándar contra una bomba de descompresión. Es ortogonal al tope absoluto de bytes, porque un stream de 200 KiB que quiere convertirse en 200 MiB está por debajo del techo de 256 MiB y sigue siendo absurdo. DeadlineMilliseconds por defecto 0, es decir, sin plazo, así que esa dimensión es estrictamente opt-in. Los valores por defecto como conjunto son deliberadamente generosos, pensados para que los documentos de negocio normales no tropiecen con nada antes que para ser un perfil endurecido; si tu entrada llega desde un formulario de subida público quieres algo más cerca de los números de abajo, junto con las reglas descritas en endurecer un panel de previsualización de PDF en Delphi

Diagrama de las siete dimensiones que acota TPdfParserResourceBudget en PDFium Component: MaxRecursionDepth 1024, MaxTokens 10000000, MaxObjects 4000000, MaxDecodedBytes 256 MiB, MaxAllocationBytes 512 MiB, MaxExpansionRatio 1000.0 como defensa contra la bomba de descompresión, y el límite de tiempo real opt-in DeadlineMilliseconds, todo fijado atómicamente para el proceso por ConfigurePdfParserResourceBudget
Se eligen siete dimensiones porque cada una atrapa una clase de entrada que las demás dejan pasar, con la ratio de expansión plantada entre unos pocos kilobytes de datos Flate y cientos de megabytes
uses
  FPdfPdfCommon;

procedure ApplyUntrustedIntakeBudget;
var
  Budget: TPdfParserResourceBudget;
begin
  Budget := TPdfParserResourceBudget.Default;
  // Valores por defecto conservados: MaxRecursionDepth 1024, MaxTokens 10000000,
  // MaxObjects 4000000
  Budget.MaxDecodedBytes      := Int64(32) * 1024 * 1024;  // por defecto 256 MiB
  Budget.MaxAllocationBytes   := Int64(64) * 1024 * 1024;  // por defecto 512 MiB
  Budget.MaxExpansionRatio    := 50.0;                     // por defecto 1000.0
  Budget.DeadlineMilliseconds := 4000;                     // por defecto 0 = apagado
  ConfigurePdfParserResourceBudget(Budget);
end;

¿Por qué tomar una instantánea en lugar de leer el global?

Porque un límite que puede cambiar mientras se está aplicando no es un límite. Este es el punto de diseño central de toda la funcionalidad, y es una línea de disciplina en cada punto de entrada: lee la política una vez al inicio de la operación en un TPdfParserResourceBudget local, y luego haz que cada checkpoint compare contra esa copia local. GetPdfParserResourceBudget toma la sección crítica y devuelve una copia por valor del record, y a partir de ahí el parse no vuelve a tocar estado global mutable. El resultado es que una llamada a ConfigurePdfParserResourceBudget desde otro thread afecta a la siguiente operación, nunca a la que está en vuelo. Los parses concurrentes corren bajo políticas distintas sin interferirse, una operación que falló por un límite de recursos falló contra un único conjunto coherente de umbrales, y no hay contención de lock en el camino caliente porque el lock se toma una vez por parse y no una vez por token

Diagrama de la política de instantánea en PDFium Component: el record TPdfParserResourceBudget global del proceso está detrás de una sección crítica, cada parse llama una vez a GetPdfParserResourceBudget para una copia por valor, cada checkpoint compara contra esa instantánea local, y una llamada concurrente a ConfigurePdfParserResourceBudget desde otro thread afecta solo a la siguiente operación, nunca a la que está en vuelo
Leer la política una vez en un record local significa que un cambio de configuración desde otro thread afecta solo a la siguiente operación, y no hay contención de lock en el camino caliente
type
  TCustomScanState = record
    Budget: TPdfParserResourceBudget;
    StartTick: Cardinal;
    TokenCount: Int64;
    Depth: Integer;
  end;

procedure BeginScan(var S: TCustomScanState);
begin
  S.Budget := GetPdfParserResourceBudget;  // instantánea tomada una vez, al principio
  S.StartTick := PdfParserStartTick;
  S.TokenCount := 0;
  S.Depth := 0;
end;

function ScanCheckpoint(var S: TCustomScanState): Boolean;
begin
  Result := False;
  if PdfParserDeadlineReached(S.StartTick, S.Budget) then
    Exit;
  Inc(S.TokenCount);
  if S.TokenCount > S.Budget.MaxTokens then
    Exit;
  if S.Depth > S.Budget.MaxRecursionDepth then
    Exit;
  Result := True;
end;

Si escribes tu propio parser sobre la librería, copia esa forma en lugar de llamar a GetPdfParserResourceBudget dentro de tu bucle de tokens. Llamarla por token es correcto pero inútil: pagas una sección crítica en cada iteración y reintroduces la deriva a mitad de operación que la instantánea existía para evitar

¿Por qué un techo de bytes no frena un archivo patológico?

Porque el tamaño y el coste están solo débilmente relacionados en PDF. Un archivo de 400 KiB con un cross-reference stream de ISO 32000-1 §7.5.8 cuyos arrays /Index y /W describen un recuento de entradas plausible pero hostil, una cadena de object streams que vuelve sobre los mismos offsets, o un content stream de operadores que cada uno cuesta más de lo que parece, se quedará cómodamente bajo cualquier techo de bytes y objetos que fijes y aun así ocupará un worker thread durante minutos. Los recuentos de bytes acotan la memoria; no acotan el tiempo. Para eso están PdfParserStartTick y PdfParserDeadlineReached. PdfParserStartTick captura un valor de tick al inicio de la operación y PdfParserDeadlineReached compara el tiempo transcurrido contra DeadlineMilliseconds, devolviendo False siempre que el plazo sea 0. La resta corre en aritmética sin signo, así que un rollover del contador de ticks no produce un timeout espurio. El parseo de cross-references, la decodificación de object streams, la normalización de tokens de contenido y los lectores de FDF y XFDF lo comprueban todos en las cabeceras de sus bucles, así que un parse que se pasa de presupuesto se detiene en un checkpoint en lugar de ser matado desde fuera

Dos avisos honestos. Es tiempo de pared, no tiempo de CPU, así que una máquina que hace swapping o un build server bajo carga puede disparar un plazo que una máquina tranquila superaría, lo que aboga por fijarlo muy por encima de tu peor caso observado y no cerca de tu mediana. Y está desactivado por defecto, porque un plazo que se dispara con un documento legítimamente grande durante el procesamiento por lotes de fin de mes es un fallo peor que el que previene. Actívalo por camino de ingesta y no globalmente, y acompáñalo de las comprobaciones estructurales de validar cross-reference streams

El presupuesto es un techo, no un sustituto de los límites de formato

Cada parser conserva sus constantes específicas de formato y aplica ambas. XFDF es el ejemplo más claro: el lector de campos impone XfdfMaxTopLevelFields y una constante MaxFieldNodes de 100000 junto al valor de la instantánea, y cualquiera de las dos puede detener el parse

const
  XfdfMaxTopLevelFields = 100000;   // local al formato, siempre aplica
...
  // Ambos techos están activos; gana el más bajo
  if (FieldCount >= XfdfMaxTopLevelFields) or
     (FieldCount >= Budget.MaxObjects) then
    Exit(False);

Esto es intencional y merece la pena interiorizarlo antes de ir a buscar un mando que no existe. Un presupuesto global de 4.000.000 de objetos no le concede a un archivo XFDF 4.000.000 de campos, porque 100000 campos ya está muy lejos de lo que produce una exportación real de anotaciones, y subir un techo global no debería ensanchar en silencio una comprobación de cordura específica del formato. La normalización de contenido funciona igual desde la otra dirección: TPdfContentNormalizeOptions.Default parte de sus propios límites por página y luego recorta cada uno de ellos hasta la instantánea, así que MaxTokensPerPage solo puede bajar por culpa del presupuesto, nunca subir. La política global aprieta; nunca afloja

Diagrama de cómo el presupuesto del parser actúa como techo en PDFium Component: el lector de campos XFDF impone XfdfMaxTopLevelFields en 100000 junto al valor de instantánea de MaxObjects así que el más bajo detiene el parse, mientras que TPdfContentNormalizeOptions recorta MaxTokensPerPage hasta el presupuesto, porque la política global aprieta los límites y nunca los afloja
Un presupuesto global de 4.000.000 de objetos no le concede a un archivo XFDF 4.000.000 de campos, porque cada parser conserva sus constantes específicas de formato y aplica ambos techos ganando el más bajo

La configuración mala falla cuando la configuras

ConfigurePdfParserResourceBudget valida antes de asignar. Un valor no positivo en cualquiera de las seis dimensiones numéricas salta de inmediato, y un MaxDecodedBytes por encima de High(Integer) se rechaza por exceder los límites de la plataforma en lugar de truncarse en silencio en algún lugar profundo de un decoder. Esto importa más de lo que parece: un presupuesto construido desde un archivo de configuración al que le falta una clave te da ceros, y un techo de objetos a cero aceptado al arrancar se convierte en un fallo de parse desconcertante con el primer documento tres horas después, momento en el que nadie sospecha de la configuración. El mismo cuidado se aplica a cualquier código que apriete el presupuesto temporalmente, incluidos los tests. La política es global al proceso, así que dejar un plazo de 4 segundos instalado después de que un fixture termine envenena todos los posteriores; guarda y restaura alrededor del cambio

var
  Saved: TPdfParserResourceBudget;
begin
  Saved := GetPdfParserResourceBudget;
  try
    ApplyUntrustedIntakeBudget;
    ProcessInboundDocument(FileName);
  finally
    ConfigurePdfParserResourceBudget(Saved);
  end;
end;

ResetPdfParserResourceBudget es la versión más contundente, que restaura TPdfParserResourceBudget.Default sin más. Úsalo en el teardown de los tests donde quieras un estado conocido, y usa guardar-y-restaurar en código de producción, donde el estado al que debes volver es el que la aplicación anfitriona configuró al arrancar

Dónde el presupuesto deja de ayudar

Es una política única global al proceso, y esa es su limitación principal. No puedes darle a un worker thread un perfil estricto y a otro uno permisivo, porque hay un solo record detrás de una sección crítica; si de verdad necesitas límites por tenant, la forma que funciona es serializar cada clase de política en su propio proceso, o delimitar el trabajo con guardar-y-restaurar y aceptar que el delimitador solo es seguro cuando nada más está parseando en paralelo. También acota el parseo, no el renderizado: la memoria de rasterización, el crecimiento de la caché de fuentes y la decodificación de imágenes quedan fuera de estas siete dimensiones y necesitan sus propios controles. Y un presupuesto te dice que una operación fue demasiado cara, nunca por qué. El techo que saltó es el síntoma, y el array /Index malformado o la cadena de filtros desbocada que hay detrás todavía hay que diagnosticarlos a mano, que es por lo que merece la pena entender el camino de decodificación que describe la decodificación de object streams y predictores antes de empezar a mover números. Trata el presupuesto como lo que evita que un documento malo se lleve el proceso por delante, y mantén tu logging lo bastante bueno para averiguar qué estaba haciendo el documento

Toda la superficie de API del parser, el record de presupuesto y una build con código fuente para Delphi, C++Builder y Lazarus están en la página del producto: PDFium Component