אותו קוד מקור של Object Pascal יכול להתנהג באופן שונה תחת דלפי (Delphi) ותחת FPC/Lazarus בארבע דרכים המכשילות שוב ושוב את קוד רכיב PDFium: מנוע FPC משחרר משתני רשומות זמניים המוחזרים מפונקציות לפני שבדיקת חברות של in מסיימת לקרוא אותם, dcc32 מגיע כברירת מחדל עם בדיקת טווחים כבויה כך שאינדקסים מחוץ לגבולות המערך קוראים זבל בשקט, רק Delphi 13 מקבל הקצאה של array of Byte אנונימי ל-TBytes ללא המרה (cast), ושרשור AnsiString בדלפי עלול להרוס בתים בערך $80 ומעלה דרך סבב נסתר של דף קוד (code-page round-trip). כל אחד מאלה מייצר ערכת בדיקות שהיא ירוקה בקומפיילר אחד ואדומה, או גרוע מכך, שגויה בשקט, בקומפיילר השני
אם אתם מגדירים פרויקט דו-קומפיילר בפעם הראשונה, המדריך למציג של Lazarus ו-FPC מכסה את הנתיב המוצלח: חבילות, נתיבי חיפוש והצגת חלון תצוגה על המסך. מאמר זה הוא ההפך ממדריך. הוא רשימת הדברים שנתקלנו בהם לאחר שהנתיב המוצלח עבד, כאשר ה-CI היה ירוק תחת FPC, ירוק תחת דלפי, ואז שינוי שעבר בצד אחד התפוצץ בצד השני. כל מלכודת להלן מגיעה מכשל אמיתי בערכת הבדיקות של PDFiumPas או בדמויים שלה, כאשר ניתוח האירוע ברמת ה-commit מתומצת לשחזור מינימלי, הגורם הבסיסי והתיקון שעבר סטנדרטיזציה
מדוע קבוצה (set) נקראת כריקה תחת FPC אך לא בדלפי?
הגרסה במשפט אחד: FPC עלול לסיים (finalize) את המשתנה הזמני המחזיק בתוצאת הרשומה של פונקציה לפני שביטוי הקורא שדה של תוצאה זו הסתיים, כך ש-X in Func().Issues יכול לבדוק חברות מול קבוצה שכבר שוחררה, בעוד שהביטוי המקביל בדלפי עובד. בדיקות תאימות ה-PDF/E שלנו נתקלו בכך בגרסתן הראשונה. המאמת מחזיר רשומה ששדה ה-Issues שלה הוא קבוצה של דגלי הפרה, וה-assertions שילבו את הקריאה בגוף הקוד
// לא אמין תחת FPC: הרשומה הזמנית של תוצאת הפונקציה
// עלולה להשתחרר לפני שבדיקת 'in' קוראת את Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// אמין בשני הקומפיילרים: קבעו את התוצאה למשתנה מקומי תחילה
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
הצורה המשולבת קראה את הקבוצה כריקה תחת FPC, כך שכל assertion שציפה לדגל נכשל, בעוד שהבנייה הציפית בדלפי עברה בהצלחה. גורם השורש הוא הבדל באופו שבו שני הקומפיילרים מנהלים את אורך החיים של משתנים זמניים המוחזרים מפונקציות בתוך ביטויים גדולים יותר: דלפי שומר על המשתנה הזמני חי עד סוף המשפט, בעוד שהשחרור של הרשומה הזמנית ב-FPC עלול להתחרות עם אופרטור החברות של הקבוצה שעדיין קורא אותה. כבר תיעדנו את אותה התנהגות פעם אחת בעבר, בהערה על עזר ה-FlagPresent ביחידת בדיקות ה-PDF/A, ואז הצגנו מחדש את הבאג בכל זאת בעת כתיבת בדיקות חדשות מאפס, מה שמספר לכם עד כמה הצורה השבורה נראית טבעית. התיקון הוא מכני וראוי לאימוץ ככלל גורף: לעולם אל תשרשרו גישה לשדה או בדיקת קבוצה ישירות לקריאת פונקציה המחזירה רשומה; הקצו את התוצאה למשתנה מקומי תחילה, ואז קיראו את השדה. זה עולה שורה אחת ומסיר מחלקה שלמה של חוסר יציבות התלוי בקומפיילר
מדוע דלפי מקבל אינדקס מערך ש-FPC מסרב לקמפל?
הגרסה במשפט אחד: dcc32 מקמפל אינדקס מחוץ לטווח לתוך מערך בעל גבולות קבועים ובעזרת בדיקת טווחים כבויה כברירת מחדל, קורא או כותב זיכרון סמוך בזמן ריצה ללא כל שגיאה, בעוד ש-FPC פוסל את אותו אינדקס בזמן קומפילציה. רכיב PDFium מצהיר על נקודות מרובע כמערך מבוסס 1 (1-based), כלומר TQuadrilateralPoint = array [1..4] of TPdfPoint, התואם לאופן שבו ערכי QuadPoints של PDF ממוספרים בדרך כלל. דמו שמילא אותו בלולאה מבוססת 0 עבד במשך חודשים תחת דלפי
var
I: Integer;
begin
for I := 0 to 3 do // שגוי: המערך הוא [1..4]
Data.AttachmentPoints[I] := Corner[I]; // ברירת המחדל של dcc32: מתקמפל, אינדקס 0
// נוגע בשקט בזיכרון סמוך
// FPC: שגיאת בדיקת טווח בזמן קומפילציה
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // נכון בשני הקומפיילרים
end;
הבנייה בדלפי הייתה false positive: כאשר בדיקת הטווחים כבויה, שזהו מחדל המערכת ב-dcc32, אינדקס 0 נחת על שדה כלשהו הקודם למערך ברשומה, והדמו נראה כרץ. העברת אותו דמו ל-Lazarus ייצרה מיד שגיאת בדיקת טווח בזמן קומפילציה מצד FPC, ותיקון האינדקס חשף אז באג שני ועמוק יותר בנתיב ההערות של הספרייה שקריאות הזבל מסכו, זה המנותח במאמר על הערות QuadPoints. שני לקחים עלו מהאירוע. הראשון, העדיפו את Low() ו-High() על פני גבולות מילוליים בכל פעם שסוג המערך אינו מבוסס 0 מעצם בנייתו. השני, התייחסו לקומפילציית FPC, או לפחות לבניית דלפי אחת עם {$R+} פעיל, כאל שער חובה להרצה ראשונה של כל דמו או בדיקה חדשים: ברירות המחדל של dcc32 לא יספרו לכם על מחלקת באגים זו, ותוכנית שרצה אינה הוכחה לכך שהיא נכונה
הקצאת TBytes שרק Delphi 13 מקבל
הגרסה במשפט אחד: הקצאת שדה המוצהר כ-array of Byte אנונימי למשתנה TBytes מתקמפלת ב-Delphi 13 (גרסת קומפיילר 37.0) אך נכשלת ב-Delphi 12 Athens ובכל גרסה מוקדמת יותר עם E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. מקרה זה אינו פיצול Delphi מול FPC אלא פיצול של Delphi מול עברה שלה, אך הוא פוגע באותו קוד מרובה קומפיילרים באותו אופן: הקומפיילר החדש ביותר מקבל בשקט מבנה שכל השאר פוסלים
type
TValidator = class
private
FBuffer: array of Byte; // סוג מערך דינמי אנונימי
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // Delphi 13 בלבד; E2010 ב-Delphi 12
// Athens ומטה
OrigBytes := TBytes(FBuffer); // מתקמפל בכל מקום; אותה פריסת בייטים,
// המרה קשה בטוחה
end;
שלחנו בדיוק את זה בשגרת אימות, שפותחה ונבדקה מקומית ב-Delphi 13, שבה ההמרה המשתמעת התקבלה בשקט. מתקין הקוד המלא משרת משתמשים ב-Delphi 12 ומטה במספרים גדולים, ועבורם היחידה פשוט לא התקמפלה. התיקון המבני הוא המרה קשיחה (hard cast) כפי שמוצג לעיל, שהיא בטוחה מכיוון ש-array of Byte אנונימי ו-TBytes חולקים מבנה מערך דינמי זהה, או עדיף מכך, הצהרה על השדה כסוג בעל שם כגון TBytes מלכתחילה כך ששום המרה לא תידרש. תיקון התהליך חשוב יותר: מבנה המתקמפל בשרשרת הכלים החדשה ביותר שלכם אינו מוכיח דבר על קומפיילרים ישנים יותר שהמשתמשים שלכם מריצים בפועל, ועבורם התיקון המצוין נדרש
בית ה-AnsiString שנעלם במכונת Windows סינית
הגרסה במשפט אחד: שרשור בית גולמי בערך $80 ומעלה לתוך AnsiString באמצעות + עלול להחליף בשקט בית זה ב-? ($3F) תחת דלפי, מכיוון שהביטוי לוקח סבב משתמע של AnsiString ל-UnicodeString ל-AnsiString דרך דף הקוד של המערכת. מצאנו זאת דרך בדיקת PDF/A הבונה שם המכיל בית $FE מבודד, שלעולם אינו בית מוביל תקין של UTF-8, כדי לאמת שהמאמת מסמן שמות שאינם UTF-8 תקינים לפי תקן ISO 19005-2 סעיף 6.1.8
var
BadName: AnsiString;
begin
// ב-Delphi עם עמוד קוד מערכת רב-בייטי (נצפה ב-CP936),
// השרשור עובר סבב דרך UnicodeString, ו-$FE, שאינו
// רצף CP936 תקף, חוזר כ-'?' ($3F)
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// בטוח: בנו עם ממלא-מקום ASCII, ואז תקנו את הבייט במקום;
// השמה מאונדקסת לתוך AnsiString מיושבת אינה עוברת סבב
BadName := '/Bad' + #1 + 'Name';
BadName[5] := AnsiChar($FE);
end;
במערכת Windows סינית המריצה את דף הקוד 936, המחרוזת המשורשרת לא הכילה $FE כלל, ולכן הספרייה דיווחה בצדק על שום דבר והבדיקה הפכה לאדומה תוך שהיא נראית כמו באג של הספרייה. הספרייה מעולם לא טעתה: הרצת FPC שהזינה קובץ PDF המכיל באמת את הבית $FE קיבלה את הדגל הצפוי. ההשחתה התרחשה בתוך קובץ הבדיקה של דלפי בזמן שביטוי המחרוזת הוערך, מכיוון שמודל מחרוזות ה-Unicode תחילה של דלפי ממיר ביטויי AnsiString מעורבים דרך UnicodeString, ו-$FE אינו בית מוביל תקין ב-CP936 ולכן הסבב מחליף אותו. היו כנים לגבי הגבול כאן: בדפי קוד מערביים של בית בודד כגון CP1252 אותו ביטוי בדרך כלל שורד, וזו בדיוק הסיבה שהבאג הזה מתחבא ברוב מכונות הפיתוח ומופיע רק במערכות מזרח-אסיאתיות או בשרתי CI מקומיים. הכלל שאימצנו: לעולם אל תבנו וקטורי בדיקה בינאריים המכילים בתים בערך $80 ומעלה באמצעות שרשור AnsiString; או שתתקנו את הבתים במקום לאחר שהמחרוזת התייצבה, כפי שמוצג לעיל, או שתבנו את הווקטור ב-TBytes מלכתחילה
מה שזרימת עבודה של דו-קומפיילר צריכה לבדוק כברירת מחדל
ארבע מלכודות, דפוס אחד: כל קומפיילר מספר לכם על תת-קבוצה שונה של הבאגים שלכם. ניתוח הטווחים בזמן קומפילציה של FPC תפס אינדקס מחוץ לטווח ש-dcc32 הריץ בשקט במשך חודשים, ומדל מחרוזות ה-Unicode של dcc32 חשף תלות בדפי קוד שבניית FPC טהורה וממוקדת בתים אינה מפעילה לעולם. התוצאה המעשית היא שאף אחד מצינורות ה-CI הירוקים אינו מספיק לבדו. קומפילציה צולבת (Cross-compiling) אינה רק תיבת סימון של ניידות, היא מנתח סטטי שני ומותג זמן ריצה שני המוחלים על אותו קוד מקור, באותה רוח של בדיקות הגבול המגננתיות במאמר על הקשחת אבטחת זיכרון ו-ABI
הכללים הקבועים שנבעו מאירועים אלה קצרים מספיק לשינון. הצמידו רשומות המוחזרות מפונקציות למשתנה מקומי לפני קריאת שדות. בצעו איטרציה של מערכים בעלי גבולות קבועים באמצעות Low() ו-High(), והריצו לפחות בנייה אחת עם בדיקת טווחים או בניית FPC לפני שתסמכו על דמו חדש. בצעו המרה מפורשת (cast) של שדות מערכים דינמיים אנונימיים, או הצהירו עליהם כסוגים בעלי שם, ובנו את מטריצת הקומפיילרים המלאה לפני השחרור. שימרו על בתים גבוהים גולמיים מחוץ לשרשור AnsiString לחלוטין. אף אחד מאלה אינו עולה מאמץ מדיד ברגע שהם הופכים להרגלים, וכל אחד מהם סוגר מצב כשל שזרימת עבודה של קומפיילר יחיד מבחינה מבנית אינה יכולה לראות
כל ארבע הבעיות נמצאו ותוקנו במהלך התחזוקה של רכיב PDFium, המספק את אותו קוד מקור של Object Pascal עבור Delphi, C++Builder ו-FPC/Lazarus ומריץ את ערכות התאימות והנסיגה שלו בכל אחת משרשראות הכלים הללו, כך שהמלכודות במאמר זה מוגנות על ידי בדיקות ולא על ידי זיכרון