Artículo técnico

Seis llamadas de PDFium que olvidaron el bloqueo de renderizado en Delphi

El bloqueo de renderizado de PDFiumPas es una sección crítica por documento, EnterRenderLock y LeaveRenderLock, respaldada por un campo TRTLCriticalSection en TPdf, pensada para envolver cada llamada al rasterizador de PDFium de modo que una página no pueda descargarse o recargarse por debajo de un renderizado en curso. Seis métodos, repartidos a partes iguales entre TPdf y TPdfView, llamaban directamente a las API de mapa de bits y extracción de miniaturas de PDFium y se saltaban ese bloqueo por completo, una brecha que PDFiumPas v2.26.0 cerró envolviendo los seis en el mismo par de bloqueo que ya usaba cualquier otro punto de entrada de renderizado

La brecha cubierta aquí no es la pasada de endurecimiento de ABI cubierta en otra parte de este blog, que recorría un desajuste de convención de llamada cdecl y un truncamiento de ancho de puntero de FPC Win64 en el mismo binding de PDFium. Lo que sigue es más estrecho y más mecánico: una lista de comprobación de cobertura de bloqueo para seis puntos de llamada que alcanzan todos la vía de renderizado de PDFium, por qué a cada uno le resultó fácil pasar desapercibido, y por qué la carrera que se deriva de la falta del bloqueo es uno de los defectos más difíciles de reproducir a voluntad de esta base de código

Qué protege realmente el bloqueo de renderizado

PDFiumPas serializa el renderizado porque la página cargada de PDFium no es segura de leer desde un hilo mientras otro hilo tiene libertad para liberarla. TPdf posee un TRTLCriticalSection en FRenderLock, inicializado en el constructor y protegido por un indicador FRenderLockReady para que una llamada que llegue tras el desmontaje se convierta en una operación silenciosa sin efecto en lugar de entrar en una sección crítica ya eliminada. EnterRenderLock y LeaveRenderLock son la única vía autorizada para entrar y salir de esa sección

procedure TPdf.EnterRenderLock;
begin
  if FRenderLockReady then
    EnterCriticalSection(FRenderLock);
end;

procedure TPdf.LeaveRenderLock;
begin
  if FRenderLockReady then
    LeaveCriticalSection(FRenderLock);
end;

TPdf.RenderPage, RenderTile y RenderPageProgressive ya seguían esa disciplina antes de que empezara esta auditoría concreta, cada uno tomando el bloqueo antes de llamar a PDFium y liberándolo en un bloque finally para que un prerrenderizado en segundo plano y un UnloadPage en primer plano sobre la misma instancia de TPdf no puedan solaparse. La brecha que encontró PDFiumPas v2.26.0 no estaba en esos puntos de entrada evidentes, apareció en seis métodos que se leen como accesores más que como renderizados, aunque cada uno de ellos le pide a PDFium que rasterice píxeles antes de poder devolver nada

¿Qué seis llamadas se saltaban el bloqueo de renderizado?

TPdf.GetObjectBitmap, TPdf.GetBitmap y TPdf.GetThumbnail constituían la mitad de la lista, y TPdfView.GetObjectBitmap, TPdfView.GetBitmap y TPdfView.GetThumbnail constituían la otra mitad, las mismas tres operaciones, duplicadas entre las dos clases de componente que exponen la misma página subyacente. Las seis acaban llamando a FPDFImageObj_GetBitmap o a FPDFPage_GetThumbnailAsBitmap, y ambos puntos de entrada de PDFium rasterizan sobre la marcha en lugar de devolver una referencia a algo ya renderizado. Nada en ninguno de los seis nombres de método dice render, que es una explicación razonable de por qué no se escribieron contra la misma lista de comprobación que RenderPage y RenderTile la primera vez

function TPdf.GetObjectBitmap(Index: Integer): TBitmap;
var
  Bitmap: FPDF_BITMAP;
begin
  Result:= nil;
  EnterRenderLock;
  try
    Bitmap:= FPDFImageObj_GetBitmap(GetObjectHandle(Index));
  finally
    LeaveRenderLock;
  end;
  if Bitmap<> nil then
    try
      Result:= ToBitmap(Bitmap);
    finally
      FPDFBitmap_Destroy(Bitmap);
    end;
end;

Por qué TPdfView protege su llamada de bloqueo con una comprobación de nil

TPdfView no posee su propia sección crítica, cada una de sus seis llamadas de bloqueo reenvía a FPdf.EnterRenderLock y FPdf.LeaveRenderLock, envuelta primero en una comprobación de que la referencia TPdf asociada no sea nil. Esa protección existe porque un TPdfView puede estar sobre un formulario en tiempo de diseño, o brevemente entre el cierre de un documento y la apertura del siguiente, sin ningún TPdf todavía asignado a FPdf. Saltarse la protección cambiaría un fallo por otro, ya que una llamada de bloqueo contra una referencia nil no falla con más elegancia que la carrera que el bloqueo existe para evitar

function TPdfView.GetThumbnail: TBitmap;
var
  PdfBitmap: FPDF_BITMAP;
begin
  CheckActive;
  Result:= nil;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  try
    PdfBitmap:= FPDFPage_GetThumbnailAsBitmap(Page);
  finally
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
  if PdfBitmap<> nil then
    try
      Result:= ToBitmap(PdfBitmap);
    finally
      FPDFBitmap_Destroy(PdfBitmap);
    end;
end;

¿Por qué pertenece RenderPage(HDC) a la misma auditoría?

TPdfView.RenderPage contra un contexto de dispositivo no es una de las seis, apareció una versión antes, en PDFiumPas v2.25.0, y se gana un sitio en esta lista de comprobación porque es el mismo defecto con una firma distinta. Esa sobrecarga llamaba a FPDF_RenderPage directamente sin EnterRenderLock ni la llamada a SetArithmeticMask que protege contra excepciones de FPU en compiladores Delphi más antiguos, mientras que la sobrecarga TBitmap situada unas líneas más abajo en la misma clase ya llevaba ambas. Que dos pasadas de auditoría atrapen el mismo modo de fallo con una versión de diferencia dice menos sobre un método concreto y más sobre la forma del error: se esconde en cualquiera que sea la sobrecarga que nadie vuelve a releer una vez que su hermana parece correcta

procedure TPdfView.RenderPage(DeviceContext: HDC; Left, Top, Width,
  Height: Integer; Rotation: TRotation; Options: TRenderOptions);
var
  ArithmeticMask: TArithmeticMask;
begin
  CheckActive;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  ArithmeticMask:= SetArithmeticMask;
  try
    FPDF_RenderPage(DeviceContext, FPage, Left, Top, Width, Height,
      Ord(Rotation), EncodeRenderOptions(Options));
  finally
    RestoreArithmeticMask(ArithmeticMask);
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
end;

¿Por qué es esta carrera casi imposible de reproducir?

La brecha de bloqueo de renderizado de PDFiumPas no falla en cada ejecución, ni siquiera en la mayoría de las ejecuciones, porque necesita que dos cosas concretas coincidan a la vez sobre la misma instancia de TPdf: una llamada de rasterización ya en curso, y un UnloadPage o ReloadPage concurrente que llegue dentro de esa misma ventana. Las pruebas de un solo hilo nunca ejercitan la vía en absoluto, e incluso las cargas de trabajo genuinamente multihilo solo la disparan cuando un renderizado en segundo plano y un evento de ciclo de vida de documento se solapan por casualidad dentro de la vida de una página. El desencadenante más realista es el prerrenderizado de PDF en segundo plano construido sobre futuros cancelables, donde un hilo de trabajo rasteriza la siguiente página mientras el hilo de interfaz recarga o descarga la actual según la entrada del usuario

FPDFImageObj_GetBitmap y FPDFPage_GetThumbnailAsBitmap recorren estructuras de objeto de página que UnloadPage tiene libertad para liberar a mitad del recorrido, así que una carrera que realmente se dispara tampoco siempre produce una violación de acceso inmediata. Una estructura leída un instante demasiado tarde puede igual de fácilmente devolver píxeles basura, o corromper metadatos del montón que solo hacen fallar varias reservas sin relación mucho más tarde, en una función que nunca tocó una página PDF. Esa es la razón honesta por la que esta clase de fallo puede sobrevivir en una base de código a través de varios ciclos de versión: la traza de pila en el punto de fallo raramente apunta cerca de las seis líneas a las que realmente les faltaba un bloqueo

Qué cambia para quien llama

GetBitmap, GetObjectBitmap, GetThumbnail, y la sobrecarga HDC de RenderPage conservan sus firmas públicas exactamente como estaban, ya que la solución es bloqueo interno añadido alrededor de llamadas existentes en lugar de una migración. Merece la pena recordar que el bloqueo de renderizado tiene ámbito por instancia de TPdf, no global al proceso, así que dos hilos renderizando dos documentos cargados por separado siguen ejecutándose completamente en paralelo, el bloqueo solo serializa las operaciones contra el único documento que ambos hilos resultan compartir. Si vuestro bloqueo ya es correcto y los renderizados aun así se sienten lentos al hacer zoom o desplazarse, esa es una cuestión distinta, respondida en el artículo sobre la caché de renderizado de PDFium y las tácticas de rendimiento de zoom, la corrección y la velocidad son ejes independientes aquí, y esta solución solo toca el primero

Seis métodos y una sobrecarga hermana son una fracción pequeña de la superficie de PDFium que expone PDFiumPas, pero fueron la fracción que solo se comportaba mal bajo una carga que a nadie le tocó ejecutar dentro de un depurador. El propio bloqueo de renderizado, y el conjunto completo de puntos de entrada de renderizado que ahora cubre, forman parte del componente PDFium para Delphi, C++Builder y Lazarus/FPC