Artigo Técnico

Seis Chamadas do PDFium que Esqueceram o Lock de Renderização em Delphi

O lock de renderização do PDFiumPas é uma seção crítica por documento — EnterRenderLock e LeaveRenderLock, apoiados por um campo TRTLCriticalSection em TPdf — destinado a envolver toda chamada ao rasterizador do PDFium, de modo que uma página não possa ser descarregada ou recarregada por baixo de uma renderização em andamento. Seis métodos, divididos igualmente entre TPdf e TPdfView, chamavam as APIs de bitmap e extração de miniatura do PDFium diretamente e ignoravam esse lock por completo, uma lacuna que o PDFiumPas v2.26.0 fechou envolvendo todos os seis no mesmo par de lock que todo outro ponto de entrada de renderização já usava

A lacuna coberta aqui não é a passada de reforço de ABI coberta em outro lugar neste blog, que percorreu uma incompatibilidade de convenção de chamada cdecl e um truncamento de largura de ponteiro do FPC Win64 na mesma vinculação PDFium. O que se segue é mais restrito e mais mecânico: uma lista de verificação de cobertura de lock para seis pontos de chamada que todos alcançam o caminho de renderização do PDFium, por que cada um foi fácil de perder, e por que a condição de corrida que se segue à falta do lock é um dos defeitos mais difíceis deste código-base para reproduzir sob demanda

O que o lock de renderização de fato protege

O PDFiumPas serializa a renderização porque a página carregada do PDFium não é segura para ler a partir de uma thread enquanto outra thread está livre para liberá-la. TPdf possui um TRTLCriticalSection em FRenderLock, inicializado no construtor e protegido por uma flag FRenderLockReady, de modo que uma chamada que chega depois do desmonte se torna um no-op silencioso, em vez de entrar em uma seção crítica excluída. EnterRenderLock e LeaveRenderLock são a única forma sancionada de entrar e sair dessa seção

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

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

TPdf.RenderPage, RenderTile e RenderPageProgressive já seguiam essa disciplina antes de esta auditoria em particular sequer começar, cada um tomando o lock antes de chamar o PDFium e liberando-o em um bloco finally, de modo que uma pré-renderização em segundo plano e um UnloadPage em primeiro plano na mesma instância de TPdf não pudessem se sobrepor. A lacuna que o PDFiumPas v2.26.0 encontrou não estava nesses pontos de entrada óbvios — apareceu em seis métodos que se leem como acessores, e não como renderizações, mesmo que cada um deles peça ao PDFium para rasterizar pixels antes de poder retornar qualquer coisa

Quais seis chamadas ignoraram o lock de renderização?

TPdf.GetObjectBitmap, TPdf.GetBitmap e TPdf.GetThumbnail compunham metade da lista, e TPdfView.GetObjectBitmap, TPdfView.GetBitmap e TPdfView.GetThumbnail compunham a outra metade — as mesmas três operações, duplicadas entre as duas classes de componente que expõem a mesma página subjacente. Todas as seis eventualmente chamam ou FPDFImageObj_GetBitmap ou FPDFPage_GetThumbnailAsBitmap, e ambos esses pontos de entrada do PDFium rasterizam na hora, em vez de devolver uma referência a algo já renderizado. Nada em nenhum dos seis nomes de método diz render, o que é uma explicação razoável de por que não foram escritos contra a mesma lista de verificação que RenderPage e RenderTile na primeira 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 que o TPdfView protege sua chamada de lock com uma verificação de nil

TPdfView não possui sua própria seção crítica — cada uma de suas seis chamadas de lock encaminha para FPdf.EnterRenderLock e FPdf.LeaveRenderLock, envolvida em uma verificação de que a referência TPdf associada não é nil primeiro. Essa proteção existe porque um TPdfView pode sentar em um formulário em tempo de design, ou brevemente entre um documento fechando e o próximo abrindo, sem nenhum TPdf atribuído a FPdf ainda. Pular a proteção trocaria uma quebra por outra, já que uma chamada de bloqueio contra uma referência nil falha não menos graciosamente que a condição de corrida que o lock 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 que RenderPage(HDC) pertence à mesma auditoria?

TPdfView.RenderPage contra um contexto de dispositivo não é um dos seis — apareceu em um lançamento anterior, no PDFiumPas v2.25.0, e ganha um lugar nesta lista de verificação porque é o mesmo defeito vestindo uma assinatura diferente. Essa sobrecarga chamava FPDF_RenderPage diretamente sem EnterRenderLock nem a chamada SetArithmeticMask que protege contra exceções de FPU em compiladores Delphi mais antigos, enquanto a sobrecarga TBitmap sentada algumas linhas abaixo na mesma classe já carregava ambos. Duas passadas de auditoria capturando o mesmo modo de falha um lançamento depois diz menos sobre qualquer método individual e mais sobre a forma do bug: ele se esconde em qualquer sobrecarga que ninguém relê depois que sua irmã parece correta

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 que essa condição de corrida é quase impossível de reproduzir?

A lacuna de lock de renderização do PDFiumPas não falha em toda execução, nem mesmo na maioria delas, porque precisa de duas coisas específicas pousando na mesma instância de TPdf ao mesmo tempo: uma chamada de rasterização já em andamento, e um UnloadPage ou ReloadPage concorrente chegando dentro dessa mesma janela. Testes single-thread nunca exercitam o caminho de forma alguma, e mesmo cargas de trabalho genuinamente multi-thread só disparam isso quando uma renderização em segundo plano e um evento de ciclo de vida de documento por acaso se sobrepõem dentro do tempo de vida de uma página. O gatilho mais realista é pré-renderização de PDF em segundo plano construída sobre futures canceláveis, onde uma worker thread rasteriza a próxima página enquanto a thread de UI recarrega ou descarrega a atual sob entrada do usuário

FPDFImageObj_GetBitmap e FPDFPage_GetThumbnailAsBitmap percorrem estruturas de objeto de página que UnloadPage é livre para liberar no meio da travessia, de modo que uma corrida que de fato dispara nem sempre produz uma violação de acesso imediata também. Uma estrutura lida um momento tarde demais pode facilmente devolver pixels de lixo, ou corromper metadados de heap que só quebram várias alocações não relacionadas mais tarde, em uma função que nunca tocou uma página de PDF. Esse é o motivo honesto pelo qual essa classe de bug consegue sobreviver em um código-base ao longo de vários ciclos de lançamento: o stack trace no ponto de falha raramente aponta para perto das seis linhas que de fato estavam sem um lock

O que muda para quem chama

GetBitmap, GetObjectBitmap, GetThumbnail, e a sobrecarga HDC de RenderPage mantêm suas assinaturas públicas exatamente como eram, já que a correção é bloqueio interno adicionado ao redor de chamadas existentes, em vez de uma migração. Vale lembrar que o lock de renderização tem escopo por instância de TPdf, não global ao processo, de modo que duas threads renderizando dois documentos carregados separadamente ainda rodam totalmente em paralelo — o lock só serializa operações contra o único documento que as duas threads por acaso compartilham. Se seu bloqueio já está correto e as renderizações ainda parecem lentas sob zoom ou rolagem, essa é uma questão diferente, respondida em o artigo sobre táticas de cache de renderização e desempenho de zoom do PDFium — correção e velocidade são eixos separados aqui, e essa correção só toca o primeiro

Seis métodos e uma sobrecarga irmã são uma pequena fração da superfície do PDFium que o PDFiumPas expõe, mas foram a fração que só se comportava mal sob carga que por acaso ninguém estava rodando em um depurador. O próprio lock de renderização, e o conjunto completo de pontos de entrada de renderização que agora cobre, vêm como parte do Componente PDFium para Delphi, C++Builder e Lazarus/FPC