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, respaldados por un campo TRTLCriticalSection en TPdf— pensado para envolver cada llamada al rasterizador de PDFium para que una página no pueda descargarse o recargarse mientras un renderizado está en curso por debajo. Seis métodos, repartidos equitativamente entre TPdf y TPdfView, llamaban directamente a las API de extracción de mapa de bits y miniatura de PDFium y omitían 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 otro lugar de este blog, que recorrió 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 verificación de cobertura de bloqueo para seis puntos de llamada que todos llegan hasta la ruta de renderizado de PDFium, por qué cada uno era fácil de pasar por alto, y por qué la condición de carrera que resulta de omitir el bloqueo es uno de los defectos más difíciles de reproducir a demanda en este código base

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 está libre para liberarla. TPdf posee un TRTLCriticalSection en FRenderLock, inicializado en el constructor y resguardado por una bandera FRenderLockReady para que una llamada que llegue después del 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 forma sancionada de 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 esta auditoría en particular siquiera empezara, 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 superponerse. La brecha que encontró PDFiumPas v2.26.0 no estaba en esos puntos de entrada obvios: apareció en seis métodos que se leen más como accesores que como renderizados, aunque cada uno de ellos le pide a PDFium que rasterice píxeles antes de poder devolver algo

¿Qué seis llamadas omitieron el bloqueo de renderizado?

TPdf.GetObjectBitmap, TPdf.GetBitmap, y TPdf.GetThumbnail conformaban la mitad de la lista, y TPdfView.GetObjectBitmap, TPdfView.GetBitmap, y TPdfView.GetThumbnail conformaban la otra mitad —las mismas tres operaciones, duplicadas a través de las dos clases de componente que exponen la misma página subyacente. Las seis eventualmente llaman a FPDFImageObj_GetBitmap o a FPDFPage_GetThumbnailAsBitmap, y ambos puntos de entrada de PDFium rasterizan en el acto 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 verificació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 asociada a TPdf no sea nil. Esa protección existe porque un TPdfView puede estar sentado en un formulario en tiempo de diseño, o brevemente entre que un documento se cierra y el siguiente se abre, sin ningún TPdf todavía asignado a FPdf. Omitir la protección cambiaría un fallo por otro, ya que una llamada de bloqueo contra una referencia nil no falla de forma más elegante que la condición de carrera que el bloqueo existe para prevenir

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ó en una versión anterior, en PDFiumPas v2.25.0, y se gana un lugar en esta lista de verificación porque es el mismo defecto con una firma distinta. Esa sobrecarga llamaba a FPDF_RenderPage directamente sin ni EnterRenderLock ni la llamada SetArithmeticMask que protege contra excepciones de FPU en compiladores Delphi más antiguos, mientras que la sobrecarga TBitmap sentada unas líneas más abajo en la misma clase ya llevaba ambas. Dos pasadas de auditoría atrapando el mismo modo de fallo con una versión de diferencia dice menos sobre cualquier método individual y más sobre la forma del bug: se esconde en cualquiera que sea la sobrecarga que nadie vuelve a leer una vez que su hermana se ve 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 casi imposible reproducir esta condición de carrera?

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 dos cosas específicas cayendo sobre la misma instancia de TPdf a la vez: una llamada de rasterización ya en curso, y un UnloadPage o ReloadPage concurrente llegando dentro de esa misma ventana. Las pruebas de un solo hilo nunca ejercitan la ruta en absoluto, e incluso las cargas de trabajo genuinamente multi-hilo solo la disparan cuando un renderizado en segundo plano y un evento del ciclo de vida del documento resultan superponerse dentro del tiempo de vida de una página. El disparador 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 página actual según la entrada del usuario

FPDFImageObj_GetBitmap y FPDFPage_GetThumbnailAsBitmap recorren estructuras de objeto de página que UnloadPage es libre de liberar a mitad del recorrido, así que una condición de carrera que realmente se dispara tampoco siempre produce una violación de acceso inmediata. Una estructura leída un momento demasiado tarde puede igual de fácilmente devolver píxeles basura, o corromper metadatos del heap que solo colapsan varias asignaciones no relacionadas después, en una función que nunca tocó una página PDF. Esa es la razón honesta por la que esta clase de bug puede sobrevivir en un código base a través de varios ciclos de versión: la traza de pila en el punto de fallo rara vez apunta cerca de las seis líneas a las que realmente les faltaba un bloqueo

Qué cambia para quienes llaman

GetBitmap, GetObjectBitmap, GetThumbnail, y la sobrecarga HDC de RenderPage mantienen sus firmas públicas exactamente como estaban, ya que la corrección es un bloqueo interno agregado alrededor de llamadas existentes en lugar de una migración. Vale la pena recordar que el bloqueo de renderizado está acotado por instancia de TPdf, no es global al proceso, así que dos hilos renderizando dos documentos cargados por separado todavía se ejecutan completamente en paralelo —el bloqueo solo serializa operaciones contra el único documento que ambos hilos resultan compartir. Si su bloqueo ya es sólido y los renderizados todavía se sienten lentos bajo zoom o desplazamiento, esa es una pregunta distinta, respondida en el artículo sobre tácticas de caché de renderizado y rendimiento de zoom de PDFium —la corrección y la velocidad son ejes separados aquí, y esta correcció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 eran la fracción que solo se comportaba mal bajo una carga que nadie resultaba estar ejecutando en un depurador. El propio bloqueo de renderizado, y el conjunto completo de puntos de entrada de renderizado que ahora cubre, se incluyen como parte del componente PDFium para Delphi, C++Builder, y Lazarus/FPC