Технічна стаття

Шість викликів PDFium, що забули про блокування рендера в Delphi

Блокування рендера в PDFiumPas — критична секція на кожен документ — EnterRenderLock і LeaveRenderLock, підкріплені полем TRTLCriticalSection у TPdf, — призначена огортати кожен виклик у растеризатор PDFium, щоб сторінку не можна було вивантажити чи перезавантажити з-під рендера, що виконується. Шість методів, порівну розподілених між TPdf та TPdfView, викликали API вилучення растрового зображення та мініатюри PDFium напряму й повністю пропускали це блокування — прогалину, яку PDFiumPas v2.26.0 закрив, огорнувши всі шість у ту саму пару блокування, яку вже використовувала кожна інша точка входу рендерингу

Прогалина, розглянута тут, — не той прохід посилення ABI, розглянутий деінде в цьому блозі, що проходив через невідповідність угоди виклику cdecl та обрізання ширини вказівника Win64 у FPC в тій самій прив'язці PDFium. Далі йде вужче й механічніше: контрольний список покриття блокування для шести місць виклику, що всі сягають у шлях рендерингу PDFium, чому кожне з них було легко пропустити, і чому перегони, що випливають із відсутності блокування, — один із важчих для відтворення на вимогу дефектів у цій кодовій базі

Що насправді захищає блокування рендера

PDFiumPas серіалізує рендеринг, бо завантажену сторінку PDFium небезпечно читати з одного потоку, поки інший потік вільний її звільнити. TPdf володіє TRTLCriticalSection у FRenderLock, ініціалізованим у конструкторі й охоронюваним прапорцем FRenderLockReady, тож виклик, що надходить після руйнування, стає тихим no-op замість входу в видалену критичну секцію. EnterRenderLock та LeaveRenderLock — єдиний санкціонований спосіб входу й виходу з цієї секції

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

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

TPdf.RenderPage, RenderTile та RenderPageProgressive уже дотримувалися цієї дисципліни ще до того, як цей конкретний аудит взагалі почався, кожен бере блокування перед викликом у PDFium і звільняє його в блоці finally, тож фоновий попередній рендер і передній план UnloadPage на тому самому екземплярі TPdf не можуть перетнутися. Прогалина, яку знайшов PDFiumPas v2.26.0, була не в цих очевидних точках входу — вона проявилася в шести методах, що читаються як аксесори, а не рендери, хоча кожен із них просить PDFium растеризувати пікселі, перш ніж зможе щось повернути

Які шість викликів пропустили блокування рендера?

TPdf.GetObjectBitmap, TPdf.GetBitmap та TPdf.GetThumbnail склали половину списку, а TPdfView.GetObjectBitmap, TPdfView.GetBitmap та TPdfView.GetThumbnail склали другу половину — ті самі три операції, продубльовані між двома класами компонентів, що відкривають ту саму базову сторінку. Усі шість зрештою викликають або FPDFImageObj_GetBitmap, або FPDFPage_GetThumbnailAsBitmap, і обидві ці точки входу PDFium растеризують на місці, а не повертають посилання на щось уже відрендерене. Ніщо в жодній із шести назв методів не каже render, що є розумним поясненням, чому вони не були написані проти того самого контрольного списку, що й RenderPage та 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;

Чому TPdfView охороняє свій виклик блокування перевіркою на nil

TPdfView не володіє власною критичною секцією — кожен із його шести викликів блокування переспрямовує до FPdf.EnterRenderLock та FPdf.LeaveRenderLock, огорнутих перевіркою, що пов'язане посилання TPdf спочатку не nil. Ця охорона існує, бо TPdfView може сидіти на формі під час дизайну, чи коротко між закриттям одного документа й відкриттям наступного, без жодного TPdf, присвоєного FPdf ще. Пропуск охорони обміняв би одну аварію на іншу, бо виклик блокування проти посилання nil провалюється не менш грубо, ніж перегони, для запобігання яким блокування й існує

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;

Чому RenderPage(HDC) належить тому самому аудиту?

TPdfView.RenderPage проти контексту пристрою — не один із шести — він проявився релізом раніше, у PDFiumPas v2.25.0, і заслуговує на місце в цьому контрольному списку, бо це той самий дефект, що носить інший підпис. Це перевантаження викликало FPDF_RenderPage напряму ані з EnterRenderLock, ані з викликом SetArithmeticMask, що охороняє від винятків FPU на старіших компіляторах Delphi, тоді як перевантаження TBitmap, що сидить кількома рядками нижче в тому самому класі, вже несло обидва. Два проходи аудиту, що ловлять той самий режим збою реліз потому, кажуть менше про будь-який окремий метод і більше про форму помилки: вона ховається в тому перевантаженні, яке ніхто заново не перечитує, щойно його сусід виглядає правильним

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;

Чому ці перегони майже неможливо відтворити?

Прогалина блокування рендера в PDFiumPas не провалюється при кожному запуску, і навіть не при більшості запусків, бо їй потрібні дві конкретні речі, що приземляються на тому самому екземплярі TPdf одночасно: виклик растеризації, що вже виконується, та паралельний UnloadPage чи ReloadPage, що надходить усередині того самого вікна. Одно-потокове тестування взагалі ніколи не виконує цей шлях, і навіть справді багатопотокові навантаження спричиняють його лише тоді, коли фоновий рендер і подія життєвого циклу документа випадково перетинаються в межах життя однієї сторінки. Найреалістичніший тригер — фонове попереднє рендерування PDF, побудоване на скасовуваних ф'ючерсах, де робочий потік растеризує наступну сторінку, поки потік UI перезавантажує чи вивантажує поточну на введення користувача

FPDFImageObj_GetBitmap та FPDFPage_GetThumbnailAsBitmap обходять структури об'єктів сторінки, які UnloadPage вільний звільнити посеред обходу, тож перегони, що справді спрацьовують, не завжди виробляють негайне порушення доступу теж. Структура, прочитана на мить пізніше, ніж треба, так само легко може повернути сміттєві пікселі чи пошкодити метадані купи, що обвалюється лише в кількох непов'язаних виділеннях пізніше, у функції, що ніколи не торкалася сторінки PDF. Це чесна причина, чому цей клас помилок може вижити в кодовій базі через кілька циклів релізів: трасування стека в точці збою рідко вказує будь-де поблизу тих шести рядків, яким справді бракувало блокування

Що змінюється для викликачів

GetBitmap, GetObjectBitmap, GetThumbnail та перевантаження HDC для RenderPage зберігають свої публічні сигнатури точно такими, якими були, бо виправлення — внутрішнє блокування, додане навколо наявних викликів, а не міграція. Варто пам'ятати, що блокування рендера обмежене кожним екземпляром TPdf, не глобальне для процесу, тож два потоки, що рендерять два окремо завантажені документи, все одно виконуються повністю паралельно — блокування серіалізує лише операції проти того одного документа, який обидва потоки випадково поділяють. Якщо ваше блокування вже коректне, а рендери все одно відчуваються повільними при масштабуванні чи прокрутці, це інше питання, на яке відповідає стаття про кеш рендера PDFium та тактики продуктивності масштабування — коректність і швидкість тут окремі осі, і це виправлення торкається лише першої з них

Шість методів та одне суміжне перевантаження — невелика частка поверхні PDFium, яку відкриває PDFiumPas, але саме та частка, що поводилася неправильно лише під навантаженням, яке ніхто випадково не запускав у налагоджувачі. Саме блокування рендера, і повний набір точок входу рендерингу, які воно тепер покриває, постачаються як частина компонента PDFium для Delphi, C++Builder та Lazarus/FPC