Technischer Artikel

Sechs PDFium-Aufrufe, die die Render-Sperre vergaßen, in Delphi

PDFiumPas' Render-Sperre ist eine dokumentweite kritische Sektion — EnterRenderLock und LeaveRenderLock, gestützt von einem TRTLCriticalSection-Feld an TPdf — gedacht, um jeden Aufruf in PDFiums Rasterizer zu umschließen, damit eine Seite nicht unter einem laufenden Rendering entladen oder neu geladen werden kann. Sechs Methoden, gleichmäßig verteilt zwischen TPdf und TPdfView, riefen PDFiums Bitmap- und Thumbnail-Extraktions-APIs direkt auf und übersprangen diese Sperre vollständig, eine Lücke, die PDFiumPas v2.26.0 schloss, indem alle sechs in dasselbe Sperrpaar eingewickelt wurden, das jeder andere Render-Einstiegspunkt bereits verwendete

Die hier behandelte Lücke ist nicht der ABI-Härtungsdurchlauf, der anderswo in diesem Blog behandelt wird und eine cdecl-Aufrufkonventions-Diskrepanz und eine FPC-Win64-Zeiger-Breiten-Trunkierung in derselben PDFium-Bindung durchging. Was folgt, ist enger gefasst und mechanischer: eine Sperrabdeckungs-Checkliste für sechs Aufrufstellen, die alle in PDFiums Render-Pfad hineinreichen, warum jede davon leicht zu übersehen war, und warum das daraus resultierende Rennen bei fehlender Sperre einer der schwerer auf Abruf reproduzierbaren Defekte in dieser Codebasis ist

Was die Render-Sperre tatsächlich schützt

PDFiumPas serialisiert Rendering, weil PDFiums geladene Seite nicht sicher von einem Thread gelesen werden kann, während ein anderer Thread frei ist, sie freizugeben. TPdf besitzt eine TRTLCriticalSection in FRenderLock, im Konstruktor initialisiert und von einem FRenderLockReady-Flag bewacht, sodass ein nach dem Abbau eintreffender Aufruf zu einem stillen No-op wird, statt eine gelöschte kritische Sektion zu betreten. EnterRenderLock und LeaveRenderLock sind der einzige sanktionierte Weg hinein und heraus aus dieser Sektion

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

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

TPdf.RenderPage, RenderTile und RenderPageProgressive folgten dieser Disziplin bereits, bevor dieser spezielle Audit überhaupt begann, jede von ihnen nahm die Sperre, bevor sie in PDFium hineinrief, und gab sie in einem finally-Block frei, sodass ein Hintergrund-Vor-Rendering und ein Vordergrund-UnloadPage auf derselben TPdf-Instanz sich nicht überlappen können. Die Lücke, die PDFiumPas v2.26.0 fand, lag nicht in diesen offensichtlichen Einstiegspunkten — sie tauchte in sechs Methoden auf, die sich eher wie Zugriffsmethoden lesen als wie Renderings, obwohl jede von ihnen PDFium bittet, Pixel zu rastern, bevor sie überhaupt etwas zurückgeben kann

Welche sechs Aufrufe übersprangen die Render-Sperre?

TPdf.GetObjectBitmap, TPdf.GetBitmap und TPdf.GetThumbnail machten die eine Hälfte der Liste aus, und TPdfView.GetObjectBitmap, TPdfView.GetBitmap und TPdfView.GetThumbnail machten die andere Hälfte aus — dieselben drei Operationen, dupliziert über die zwei Komponentenklassen, die dieselbe zugrunde liegende Seite freilegen. Alle sechs rufen letztlich entweder FPDFImageObj_GetBitmap oder FPDFPage_GetThumbnailAsBitmap auf, und beide dieser PDFium-Einstiegspunkte rastern an Ort und Stelle, statt eine Referenz auf etwas bereits Gerendertes zurückzugeben. Nichts in einem der sechs Methodennamen sagt "render", was eine vernünftige Erklärung dafür ist, warum sie beim ersten Mal nicht gegen dieselbe Checkliste wie RenderPage und RenderTile geschrieben wurden

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;

Warum bewacht TPdfView seinen Sperr-Aufruf mit einer Nil-Prüfung

TPdfView besitzt keine eigene kritische Sektion — jeder seiner sechs Sperr-Aufrufe leitet an FPdf.EnterRenderLock und FPdf.LeaveRenderLock weiter, umschlossen von einer Prüfung, dass die zugehörige TPdf-Referenz zuerst nicht nil ist. Diese Absicherung existiert, weil ein TPdfView zur Entwurfszeit auf einem Formular sitzen kann, oder kurz zwischen dem Schließen eines Dokuments und dem Öffnen des nächsten, ohne dass FPdf noch ein TPdf zugewiesen wäre. Die Absicherung auszulassen würde einen Absturz gegen einen anderen eintauschen, denn ein Sperr-Aufruf gegen eine Nil-Referenz scheitert nicht anmutiger als das Rennen, das die Sperre verhindern soll

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;

Warum gehört RenderPage(HDC) in denselben Audit?

TPdfView.RenderPage gegen einen Gerätekontext ist keine der sechs — sie tauchte eine Version früher auf, in PDFiumPas v2.25.0, und verdient einen Platz in dieser Checkliste, weil sie derselbe Defekt in anderer Verkleidung ist. Diese Überladung rief FPDF_RenderPage direkt auf, weder mit EnterRenderLock noch mit dem SetArithmeticMask-Aufruf, der gegen FPU-Exceptions auf älteren Delphi-Compilern schützt, während die TBitmap-Überladung ein paar Zeilen darunter in derselben Klasse bereits beides trug. Zwei Audit-Durchläufe, die denselben Fehlermodus eine Version auseinander fangen, sagen weniger über eine einzelne Methode aus und mehr über die Form des Bugs: Er versteckt sich in welcher Überladung auch immer niemand erneut liest, sobald ihr Geschwister korrekt aussieht

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;

Warum ist dieses Rennen nahezu unmöglich zu reproduzieren?

PDFiumPas' Render-Sperr-Lücke scheitert nicht bei jedem Lauf, nicht einmal bei den meisten Läufen, weil sie zwei spezifische Dinge braucht, die gleichzeitig auf derselben TPdf-Instanz zusammentreffen: einen bereits laufenden Rasterisierungsaufruf und ein gleichzeitiges UnloadPage oder ReloadPage, das innerhalb desselben Fensters eintrifft. Single-Thread-Testing durchläuft den Pfad überhaupt nie, und selbst echt mehr-threadige Workloads lösen es nur aus, wenn ein Hintergrund-Rendering und ein Dokument-Lebenszyklus-Ereignis zufällig innerhalb der Lebensdauer einer Seite überlappen. Der realistischste Auslöser ist Hintergrund-PDF-Vorab-Rendering, gebaut auf abbrechbaren Futures, wo ein Worker-Thread die nächste Seite rastert, während der UI-Thread die aktuelle bei Benutzereingabe neu lädt oder entlädt

FPDFImageObj_GetBitmap und FPDFPage_GetThumbnailAsBitmap durchlaufen Seitenobjekt-Strukturen, die UnloadPage frei ist, mitten im Durchlauf freizugeben, sodass ein tatsächlich auslösendes Rennen nicht immer sofort eine Zugriffsverletzung erzeugt. Eine Struktur, einen Moment zu spät gelesen, kann genauso leicht Müll-Pixel zurückgeben, oder Heap-Metadaten beschädigen, die erst mehrere unabhängige Allokationen später abstürzen, in einer Funktion, die nie eine PDF-Seite berührt hat. Das ist der ehrliche Grund, warum diese Bug-Klasse mehrere Release-Zyklen in einer Codebasis überleben kann: Der Stack-Trace zum Fehlerzeitpunkt zeigt selten irgendwo in die Nähe der sechs Zeilen, denen tatsächlich eine Sperre fehlte

Was sich für Aufrufer ändert

GetBitmap, GetObjectBitmap, GetThumbnail und die HDC-Überladung von RenderPage behalten ihre öffentlichen Signaturen exakt so, wie sie waren, da die Lösung interne Sperrung ist, hinzugefügt um bestehende Aufrufe herum, statt einer Migration. Es lohnt sich, sich zu merken, dass die Render-Sperre pro TPdf-Instanz begrenzt ist, nicht global für den Prozess, sodass zwei Threads, die zwei separat geladene Dokumente rendern, weiterhin vollständig parallel laufen — die Sperre serialisiert nur Operationen gegen das eine Dokument, das beide Threads zufällig teilen. Falls Ihre Sperrung bereits solide ist und Renderings sich unter Zoom oder Scrollen trotzdem langsam anfühlen, ist das eine andere Frage, beantwortet in dem Artikel zu PDFium-Render-Cache und Zoom-Performance-Taktiken — Korrektheit und Geschwindigkeit sind hier separate Achsen, und diese Lösung berührt nur die erste

Sechs Methoden und eine Geschwister-Überladung sind ein kleiner Bruchteil der PDFium-Oberfläche, die PDFiumPas freilegt, aber sie waren der Bruchteil, der sich nur unter Last falsch verhielt, die zufällig niemand in einem Debugger laufen ließ. Die Render-Sperre selbst, und die vollständige Menge der Render-Einstiegspunkte, die sie jetzt abdeckt, sind Teil der PDFium-Komponente für Delphi, C++Builder und Lazarus/FPC