Artykuł techniczny

Sześć wywołań PDFium, które zapomniały o blokadzie renderowania w Delphi

Blokada renderowania w PDFiumPas to sekcja krytyczna na dokument — EnterRenderLock i LeaveRenderLock, wspierane przez pole TRTLCriticalSection w TPdf — mająca opakowywać każde wywołanie do rasteryzatora PDFium, tak aby strona nie mogła zostać wyładowana lub przeładowana spod trwającego w danej chwili renderowania. Sześć metod, podzielonych po połowie między TPdf i TPdfView, wywoływało bezpośrednio API bitmap i wyodrębniania miniatur z PDFium, całkowicie pomijając tę blokadę — lukę, którą PDFiumPas v2.26.0 zamknął, opakowując wszystkie sześć w tę samą parę blokad, której używał już każdy inny punkt wejścia renderowania

Luka omówiona tutaj to nie ten sam przebieg wzmacniania ABI, opisany gdzie indziej na tym blogu, który przechodził przez niezgodność konwencji wywołań cdecl i obcinanie szerokości wskaźnika na FPC Win64 w tym samym powiązaniu PDFium. To, co następuje, jest węższe i bardziej mechaniczne: lista kontrolna pokrycia blokadą dla sześciu miejsc wywołania, które wszystkie sięgają do ścieżki renderowania PDFium, dlaczego każde z nich było łatwo przeoczyć, i dlaczego wyścig wynikający z braku blokady jest jednym z trudniejszych defektów w tej bazie kodu do odtworzenia na żądanie

Co faktycznie chroni blokada renderowania

PDFiumPas serializuje renderowanie, ponieważ wczytana strona PDFium nie jest bezpieczna do odczytu z jednego wątku, podczas gdy inny wątek może swobodnie ją zwolnić. TPdf posiada TRTLCriticalSection w FRenderLock, inicjalizowaną w konstruktorze i strzeżoną flagą FRenderLockReady, tak aby wywołanie przychodzące po demontażu stawało się cichym no-opem zamiast wchodzić do usuniętej sekcji krytycznej. EnterRenderLock i LeaveRenderLock to jedyny usankcjonowany sposób wejścia i wyjścia z tej sekcji

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

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

TPdf.RenderPage, RenderTile i RenderPageProgressive już przestrzegały tej dyscypliny, zanim ten konkretny audyt się w ogóle rozpoczął, każda z nich zajmowała blokadę przed wywołaniem PDFium i zwalniała ją w bloku finally, tak aby renderowanie w tle a UnloadPage na pierwszym planie na tej samej instancji TPdf nie mogły się nakładać. Luka, którą znalazł PDFiumPas v2.26.0, nie tkwiła w tych oczywistych punktach wejścia — ujawniła się w sześciu metodach, które czytają się jak akcesory, a nie jak renderowanie, mimo że każda z nich prosi PDFium o zrasteryzowanie pikseli, zanim może cokolwiek zwrócić

Które sześć wywołań pominęło blokadę renderowania?

TPdf.GetObjectBitmap, TPdf.GetBitmap i TPdf.GetThumbnail stanowiły połowę listy, a TPdfView.GetObjectBitmap, TPdfView.GetBitmap i TPdfView.GetThumbnail stanowiły drugą połowę — te same trzy operacje, zduplikowane między dwiema klasami komponentów udostępniającymi tę samą bazową stronę. Wszystkie sześć w końcu wywołuje albo FPDFImageObj_GetBitmap, albo FPDFPage_GetThumbnailAsBitmap, a oba te punkty wejścia PDFium rasteryzują na miejscu, zamiast zwracać odwołanie do czegoś już wyrenderowanego. Nic w żadnej z sześciu nazw metod nie mówi „render", co jest rozsądnym wyjaśnieniem, dlaczego nie zostały napisane pierwszy raz według tej samej listy kontrolnej co RenderPage i 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;

Dlaczego TPdfView osłania swoje wywołanie blokady sprawdzeniem nil

TPdfView nie posiada własnej sekcji krytycznej — każde z jego sześciu wywołań blokady przekazuje dalej do FPdf.EnterRenderLock i FPdf.LeaveRenderLock, opakowane w sprawdzenie, że powiązane odwołanie TPdf nie jest najpierw nil. To zabezpieczenie istnieje, ponieważ TPdfView może znajdować się na formularzu w czasie projektowania, lub krótko między zamknięciem jednego dokumentu a otwarciem następnego, bez żadnego TPdf przypisanego jeszcze do FPdf. Pominięcie tego zabezpieczenia zamieniłoby jedną awarię na drugą, ponieważ wywołanie blokujące na odwołaniu nil zawodzi nie mniej gwałtownie niż wyścig, przed którym blokada ma chronić

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;

Dlaczego RenderPage(HDC) należy do tego samego audytu?

TPdfView.RenderPage na kontekst urządzenia nie jest jednym z tych sześciu — ujawnił się jedno wydanie wcześniej, w PDFiumPas v2.25.0, i zdobywa sobie miejsce na tej liście kontrolnej, ponieważ to ten sam defekt noszący inny podpis. Ta przeciążona wersja wywoływała FPDF_RenderPage wprost, bez EnterRenderLock i bez wywołania SetArithmeticMask, które chroni przed wyjątkami FPU na starszych kompilatorach Delphi, podczas gdy przeciążenie TBitmap leżące kilka linii niżej w tej samej klasie miało już oba te elementy. Dwa przebiegi audytu wychwytujące ten sam tryb awarii jedno wydanie od siebie mówią mniej o pojedynczej metodzie, a więcej o kształcie tego błędu: ukrywa się w tym przeciążeniu, którego nikt nie czyta ponownie, gdy jego brat bliźniak wygląda poprawnie

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;

Dlaczego ten wyścig jest niemal niemożliwy do odtworzenia?

Luka blokady renderowania w PDFiumPas nie zawodzi przy każdym uruchomieniu, ani nawet przy większości uruchomień, ponieważ potrzebuje dwóch konkretnych rzeczy, które muszą wylądować na tej samej instancji TPdf naraz: wywołania rasteryzacji już w trakcie i równoczesnego UnloadPage lub ReloadPage przychodzącego w tym samym oknie czasowym. Testowanie jednowątkowe nigdy w ogóle nie ćwiczy tej ścieżki, a nawet naprawdę wielowątkowe obciążenia uruchamiają ją tylko wtedy, gdy renderowanie w tle i zdarzenie cyklu życia dokumentu akurat nakładają się na siebie w obrębie żywotności jednej strony. Najbardziej realistycznym wyzwalaczem jest wstępne renderowanie PDF w tle zbudowane na anulowalnych futures, gdzie wątek roboczy rasteryzuje kolejną stronę, podczas gdy wątek interfejsu użytkownika przeładowuje lub wyładowuje bieżącą na skutek działania użytkownika

FPDFImageObj_GetBitmap i FPDFPage_GetThumbnailAsBitmap przechodzą przez struktury obiektów strony, które UnloadPage może swobodnie zwolnić w połowie przechodzenia, więc wyścig, który faktycznie się uruchomi, nie zawsze produkuje natychmiastowe naruszenie dostępu. Struktura odczytana chwilę za późno może równie łatwo zwrócić bezsensowne piksele, albo uszkodzić metadane sterty, które zawieszają się dopiero kilka niepowiązanych alokacji później, w funkcji, która nigdy nie dotknęła strony PDF. To uczciwy powód, dla którego ta klasa błędów może przetrwać w bazie kodu przez kilka cykli wydań: ślad stosu w miejscu awarii rzadko wskazuje gdziekolwiek w pobliżu sześciu linii, którym faktycznie brakowało blokady

Co się zmienia dla wywołujących

GetBitmap, GetObjectBitmap, GetThumbnail i przeciążenie HDC dla RenderPage zachowują swoje publiczne sygnatury dokładnie takie, jakie były, ponieważ poprawka to wewnętrzne blokowanie dodane wokół istniejących wywołań, a nie migracja. Warto pamiętać, że blokada renderowania jest zakresowa na instancję TPdf, a nie globalna dla procesu, więc dwa wątki renderujące dwa osobno wczytane dokumenty wciąż działają w pełni równolegle — blokada serializuje jedynie operacje na tym jednym dokumencie, który oba wątki akurat współdzielą. Jeśli twoje blokowanie jest już poprawne, a renderowanie wciąż wydaje się wolne przy powiększaniu lub przewijaniu, to inne pytanie, na które odpowiada artykuł o pamięci podręcznej renderowania PDFium i taktykach wydajności powiększenia — poprawność i szybkość to tutaj osobne osie, a ta poprawka dotyka tylko pierwszej z nich

Sześć metod i jedno bliźniacze przeciążenie to niewielki ułamek powierzchni PDFium, którą udostępnia PDFiumPas, ale to był ten ułamek, który zachowywał się źle wyłącznie pod obciążeniem, którego akurat nikt nie uruchamiał w debuggerze. Sama blokada renderowania oraz pełny zestaw punktów wejścia renderowania, które teraz obejmuje, są dostarczane jako część komponentu PDFium dla Delphi, C++Buildera i Lazarusa/FPC