מאמר טכני

שש קריאות PDFium ששכחו את מנעול-העיבוד ב-Delphi

מנעול-העיבוד של PDFiumPas הוא section קריטי לכל-מסמך — EnterRenderLock ו-LeaveRenderLock, נתמכים על ידי שדה TRTLCriticalSection על TPdf — נועד לעטוף כל קריאה לתוך מנוע הרסטריזציה של PDFium כך שעמוד לא יוכל להשתחרר או להיטען מחדש מתחת לעיבוד שבטיסה. שש פונקציות, מחולקות שווה בשווה בין TPdf ו-TPdfView, קראו ל-API-ים של ביטמאפ וחילוץ-תמונה-ממוזערת של PDFium ישירות ודילגו על המנעול הזה לגמרי, פער ש-PDFiumPas v2.26.0 סגר על ידי עטיפת כל שש הפונקציות באותו זוג-מנעול שכל נקודת כניסת עיבוד אחרת כבר השתמשה בו

הפער המכוסה כאן אינו מעבר חיזוק ה-ABI המכוסה במקום אחר בבלוג הזה, שעבר על אי-התאמת מוסכמת-קריאה מסוג cdecl וקיצוץ רוחב-מצביע ב-FPC Win64 באותו קישור בינארי של PDFium. מה שבא אחר כך צר יותר ומכני יותר: רשימת-בדיקה של כיסוי-מנעול עבור שש נקודות קריאה שכולן נוגעות בנתיב העיבוד של PDFium, למה כל אחת הייתה קלה לפספוס, ולמה התחרות (race) שנובעת מהיעדר המנעול היא אחד הפגמים הקשים יותר בבסיס הקוד הזה לשחזור לפי דרישה

מה מנעול-העיבוד בפועל מגן עליו

PDFiumPas מסדרת עיבוד (serializes) משום שהעמוד הטעון של PDFium לא בטוח לקרוא ממנו מתהליכון אחד בעוד תהליכון אחר חופשי לשחרר אותו. ‏TPdf מחזיקה TRTLCriticalSection ב-FRenderLock, מאותחל בבנאי ומוגן על ידי דגל FRenderLockReady כך שקריאה שמגיעה אחרי פירוק (teardown) הופכת ל-no-op שקט במקום להיכנס ל-section קריטי שנמחק. ‏EnterRenderLock ו-LeaveRenderLock הן הדרך היחידה המאושרת פנימה והחוצה מה-section ההוא

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 מצאה לא היה בנקודות הכניסה הברורות ההן — הוא הופיע בשש פונקציות שנקראות כמו accessors ולא כמו רינדורים, אף על פי שכל אחת מהן מבקשת מ-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 לא מחזיקה section קריטי משלה — כל אחת משש קריאות המנעול שלה מעבירה הלאה אל 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 מול הקשר-התקן (device context) אינה אחת מהשש — היא הופיעה בגרסה מוקדמת יותר, ב-PDFiumPas v2.25.0, והיא זוכה למקום ברשימת-הבדיקה הזו משום שהיא אותו הפגם לובש חתימה שונה. הפונקציה המועמסת (overload) ההיא קראה ל-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;

למה התחרות (race) הזו כמעט בלתי-אפשרית לשחזור?

פער מנעול-העיבוד של PDFiumPas לא נכשל בכל ריצה, או אפילו ברוב הריצות, משום שהוא זקוק לשני דברים ספציפיים כדי לנחות על אותו מופע TPdf בו-זמנית: קריאת רסטריזציה שכבר בטיסה, ו-UnloadPage או ReloadPage בו-זמני שמגיע בתוך אותו חלון. בדיקה חד-תהליכונית אף פעם לא מפעילה את הנתיב בכלל, ואפילו עומסי-עבודה רב-תהליכוניים אמיתיים רק פוגעים בו כאשר עיבוד-רקע ואירוע מחזור-חיים של מסמך במקרה חופפים בתוך מחזור-חיים של עמוד אחד. הטריגר הריאלי ביותר הוא עיבוד-מוקדם ברקע של PDF בנוי על futures ניתנים-לביטול, שבו תהליכון-עבודה מבצע רסטריזציה לעמוד הבא בעוד תהליכון ה-UI טוען-מחדש או משחרר את הנוכחי בקלט משתמש

FPDFImageObj_GetBitmap ו-FPDFPage_GetThumbnailAsBitmap עוברות על מבני אובייקט-עמוד ש-UnloadPage חופשי לשחרר באמצע-מעבר, כך שתחרות שבפועל יורה גם לא תמיד מייצרת הפרת-גישה מיידית. קריאת מבנה רגע מאוחר מדי יכולה באותה קלות למסור בחזרה פיקסלים זבל, או להשחית מטא-נתוני ערימה שמקריסה רק כמה הקצאות בלתי-קשורות מאוחר יותר, בפונקציה שאף פעם לא נגעה בעמוד PDF. זו הסיבה הכנה שהמחלקה הזו של באג יכולה לשרוד בבסיס-קוד על פני כמה מחזורי-שחרור: ה-stack trace בנקודת הכישלון לעיתים רחוקות מצביע לאיזשהו מקום ליד שש השורות שבפועל חסרו מנעול

מה משתנה עבור קוד קורא

GetBitmap, ‏GetObjectBitmap, ‏GetThumbnail, וההעמסה HDC של RenderPage שומרים על החתימות הציבוריות שלהם בדיוק כפי שהיו, שכן התיקון הוא נעילה פנימית שנוספה סביב קריאות קיימות ולא הגירה. שווה לזכור שמנעול-העיבוד מוגדר-היקף לפי מופע TPdf, לא גלובלי לתהליך, כך ששני תהליכונים שמעבדים שני מסמכים טעונים בנפרד עדיין רצים במקביל מלא — המנעול רק מסדר פעולות מול המסמך הבודד ששני התהליכונים במקרה חולקים. אם הנעילה שלך כבר איתנה ועיבודים עדיין מרגישים איטיים תחת זום או גלילה, זו שאלה שונה, נענית במאמר מטמון העיבוד וטקטיקות ביצועי הזום של PDFium — נכונות ומהירות הם צירים נפרדים כאן, והתיקון הזה נוגע רק בראשון

שש פונקציות ואחות-העמסה אחת הן חלק קטן ממשטח ה-PDFium ש-PDFiumPas חושפת, אבל הן היו החלק שרק התנהג רע תחת עומס שאף אחד במקרה לא הריץ בדיבאגר. מנעול-העיבוד עצמו, וקבוצת נקודות-הכניסה המלאה לעיבוד שהוא עכשיו מכסה, נשלחים כחלק מרכיב PDFium עבור Delphi, ‏C++Builder, ו-Lazarus/FPC