Artigo Técnico

Seis Chamadas do PDFium que Esqueciam o Render Lock em Delphi

O render lock do PDFiumPas é uma secção crítica por documento — EnterRenderLock e LeaveRenderLock, sustentadas por um campo TRTLCriticalSection na TPdf — destinada a envolver cada chamada ao rasterizador do PDFium para que uma página não possa ser descarregada ou recarregada por baixo de uma renderização em curso. Seis métodos, divididos equitativamente entre TPdf e TPdfView, chamavam diretamente as APIs do PDFium de extração de bitmap e de miniatura e ignoravam esse lock por completo, uma lacuna que a versão v2.26.0 do PDFiumPas fechou envolvendo as seis chamadas no mesmo par de lock que qualquer outro ponto de entrada de renderização já usava

A lacuna aqui tratada não é o reforço de ABI abordado noutro artigo deste blog, que percorreu uma incompatibilidade de convenção de chamada cdecl e um truncamento de largura de ponteiro no FPC Win64 na mesma vinculação do 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 acedem todos ao caminho de renderização do PDFium, porque cada um era fácil de passar ao lado, e porque a condição de corrida resultante da falta do lock é um dos defeitos mais difíceis de reproduzir a pedido nesta base de código

O que o Render Lock protege efetivamente

O PDFiumPas serializa a renderização porque a página carregada do PDFium não é segura para leitura a partir de uma thread enquanto outra thread está livre para a libertar. A TPdf possui uma TRTLCriticalSection em FRenderLock, inicializada no construtor e protegida por uma flag FRenderLockReady, de modo que uma chamada que chegue depois da destruição se torna um no-op silencioso em vez de entrar numa secção crítica já eliminada. EnterRenderLock e LeaveRenderLock são a única forma sancionada de entrar e sair dessa secção

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

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

O TPdf.RenderPage, o RenderTile, e o RenderPageProgressive já seguiam essa disciplina antes de esta auditoria em particular sequer ter começado, cada um deles obtendo o lock antes de chamar o PDFium e libertando-o num bloco finally, de forma que uma pré-renderização em segundo plano e um UnloadPage em primeiro plano na mesma instância de TPdf não se possam sobrepor. A lacuna que a v2.26.0 do PDFiumPas encontrou não estava nesses pontos de entrada óbvios — surgiu em seis métodos que se lêem como acessores em vez de renderizações, ainda que cada um deles peça ao PDFium para rasterizar pixels antes de conseguir devolver seja o que for

Quais as seis chamadas que ignoravam o render lock?

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 acabam por chamar FPDFImageObj_GetBitmap ou FPDFPage_GetThumbnailAsBitmap, e ambos estes pontos de entrada do PDFium rasterizam na hora, em vez de devolverem 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 para o facto de não terem sido escritos originalmente contra a mesma lista de verificação que o RenderPage e o RenderTile

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;

Porque é que o TPdfView protege a sua chamada de lock com uma verificação de nil

O TPdfView não possui uma secção crítica própria — cada uma das suas seis chamadas de lock é reencaminhada para FPdf.EnterRenderLock e FPdf.LeaveRenderLock, envolvida numa verificação prévia de que a referência TPdf associada não é nil. Essa proteção existe porque um TPdfView pode estar colocado numa form em tempo de design, ou brevemente entre o fecho de um documento e a abertura do seguinte, sem qualquer TPdf ainda atribuído a FPdf. Omitir a proteção trocaria um crash por outro, já que uma chamada de bloqueio contra uma referência nil não falha de forma mais elegante do que a condição de corrida que o lock 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;

Porque é que o RenderPage(HDC) pertence à mesma auditoria?

O TPdfView.RenderPage contra um contexto de dispositivo não é uma das seis chamadas — surgiu numa versão anterior, a v2.25.0 do PDFiumPas, e ganha um lugar nesta lista de verificação por ser o mesmo defeito com uma assinatura diferente. Essa sobrecarga chamava o 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 algumas linhas abaixo na mesma classe já continha ambas. Duas auditorias a apanhar o mesmo modo de falha com uma versão de intervalo dizem menos sobre qualquer método isolado e mais sobre a forma do próprio erro: esconde-se em qualquer sobrecarga que ninguém volta a reler depois de a sua irmã parecer 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;

Porque é que esta condição de corrida é quase impossível de reproduzir?

A lacuna de render lock do PDFiumPas não falha em todas as execuções, nem sequer na maioria delas, porque exige que duas coisas específicas coincidam na mesma instância de TPdf ao mesmo tempo: uma chamada de rasterização já em curso, e um UnloadPage ou ReloadPage concorrente a chegar dentro dessa mesma janela. Os testes de thread única nunca sequer exercitam este caminho, e mesmo cargas de trabalho genuinamente multithread só o despoletam quando uma renderização em segundo plano e um evento de ciclo de vida do documento calham a sobrepor-se dentro do tempo de vida de uma única página. O gatilho mais realista é a pré-renderização de PDF em segundo plano baseada em futures canceláveis, em que uma thread de trabalho rasteriza a página seguinte enquanto a thread de interface recarrega ou descarrega a atual por ação do utilizador

O FPDFImageObj_GetBitmap e o FPDFPage_GetThumbnailAsBitmap percorrem estruturas de objetos de página que o UnloadPage tem toda a liberdade para libertar a meio da travessia, pelo que uma condição de corrida que realmente ocorra também nem sempre produz uma violação de acesso imediata. Uma estrutura lida um momento tarde de mais pode facilmente devolver pixels corrompidos, ou corromper metadados do heap que só provocam o crash de várias alocações não relacionadas mais tarde, numa função que nunca sequer tocou numa página de PDF. Esta é a razão honesta pela qual esta classe de erro consegue sobreviver numa base de código ao longo de vários ciclos de lançamento: o stack trace no momento da falha raramente aponta para perto das seis linhas a quem realmente faltava um lock

O que muda para quem chama estes métodos

O GetBitmap, o GetObjectBitmap, o GetThumbnail, e a sobrecarga HDC do RenderPage mantêm exatamente as mesmas assinaturas públicas que já tinham, já que a correção é um bloqueio interno acrescentado à volta de chamadas existentes, e não uma migração. Vale a pena lembrar que o render lock tem âmbito por instância de TPdf, não é global ao processo, pelo que duas threads a renderizar dois documentos carregados separadamente continuam a correr em paralelo total — o lock só serializa operações contra o único documento que ambas as threads acontecem partilhar. Se o bloqueio já estiver correto e as renderizações continuarem a parecer lentas ao ampliar ou percorrer, essa é uma questão diferente, respondida no artigo sobre cache de renderização do PDFium e táticas de desempenho ao ampliar — correção e velocidade são eixos distintos aqui, e esta correção só toca no 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 eram a fração que só se comportava mal sob uma carga que ninguém calhava estar a correr num depurador. O próprio render lock, e o conjunto completo de pontos de entrada de renderização que agora cobre, fazem parte do Componente PDFium para Delphi, C++Builder, e Lazarus/FPC