פונקציה ב-Delphi או FPC שמחזירה רשומה לא מקבלת Result טרי ומאופס בכל קריאה. משתנה ה-Result הנסתר ההוא מתחיל באפס בדיוק פעם אחת, ושום דבר לא מאפס אותו מחדש אוטומטית בין קריאות, כך שניקוי שלו בכניסה הוא העבודה של הפונקציה עצמה. עשה את הניקוי הזה עם FillChar(Result, SizeOf(Result), 0) ומהקריאה השנייה ואילך, הפונקציה כותבת מעל הפניית מחרוזת או מערך-דינמי חי במקום לשחרר אותה, מייתמת כל בלוק ערימה (heap) שההפניה ההיא הצביעה עליו
התרחיש שבו זה נושך יומיומי. תהליך אצווה פותח ערימת קובצי PDF של צד-שלישי ועובר על כל הערה בכל עמוד, מושך את טקסט ההערה לתוך יומן ביקורת. שום דבר בלולאה ההיא לא נראה מסוכן: כל קריאה היא פונקציה פשוטה שמחזירה רשומה פשוטה, אין מצביעים בשום מקום, שום דבר שדומה לניהול זיכרון ידני בכלל. ספירת-הפניות בתוך רשומה היא כלל חשבונאות רגיל של Object Pascal, לא קוריוז ספציפי לספרייה מסוימת כלשהי, וכל בסיס-קוד ב-Delphi או FPC שמערבב FillChar עם סוגי רשומה שמחזיקות מחרוזות או מערכים דינמיים חשוף לאותו פגם
למה FillChar על תוצאת רשומה מדליף מחרוזות?
FillChar מדליפה מחרוזות משום שאין לה מושג איזה סוג נתונים היא כותבת מעליו. FillChar(X, Count, Value) עובד על כל משתנה בכלל: הוא לוקח בלוק בלתי-מוקלד של Count בייטים ומטביע כל אחד מהם עם Value, וזה כל החוזה. זה בדיוק מה שהופך את FillChar למהיר וכללי-מטרה, משום שהוא אף פעם לא בודק את הסוג של X ואף פעם לא מסתעף על מה שהבייטים הבסיסיים אומרים. שדה UnicodeString או WideString בתוך רשומה אינו התווים עצמם; הוא מצביע לבלוק ערימה שנושא ספירת-הפניות לפני נתוני התו. FillChar רואה כמה בייטים שבמקרה מחזיקים ערך מצביע וכותב מעליהם אפס בדיוק כפי שהוא היה כותב מעל שדה Integer או Double. המצביע נעלם, ספירת ההפניות שהוא היה אמור להוריד קודם אף פעם לא נגעה, והבלוק שהוא הצביע עליו יושב מוקצה עם שום דבר שנשאר מפנה אליו
איך המהדר עוקב אחר מחרוזות ומערכים דינמיים בתוך רשומה
Object Pascal קורא לסוג מנוהל כאשר המהדר חייב להריץ קוד נוסף כדי לשמור אותו נכון על פני הצבה ויציאה מהיקף. סוגי מחרוזת ארוכה כמו AnsiString, UnicodeString, ו-WideString מתאימים, וכך גם מערכים דינמיים, ממשקים, ו-Variant-ים, לצד כל רשומה או מערך קבוע שמכיל אחד מאלה כשדה. עבור כל שדה מנוהל, המהדר בשקט פולט את החשבונאות שאחרת הייתה מייגעת וקלה לטעות בה ידנית: הגדל ספירת-הפניות בהצבה, הורד אותה כשהמשתנה המחזיק נכתב-מעל או יוצא מהיקף, ושחרר את הבלוק הבסיסי ברגע שהספירה ההיא מגיעה לאפס. המנגנון הזה הוא למה קוד Pascal רגיל אף פעם לא מקצה או משחרר string ידנית, ולמה הצבת מערך דינמי אחד לאחר היא פעולה זולה ובטוחה במקום לולאת-העתקה ידנית. System.Default ו-Finalize הן שתי הדרכים המתועדות לזמן את אותה לוגיקת-שחרור לפי דרישה, והן מה שקוד-ניקוי של רשומה צריך לקרוא לו במקום מילוי-זיכרון גולמי
type
TLineItem = record
Description: string; // managed: reference-counted
Quantity: Integer; // unmanaged: plain ordinal
end;
function GetLineItem(Index: Integer): TLineItem;
begin
FillChar(Result, SizeOf(Result), 0); // clears bytes, not the reference
Result.Quantity := Source[Index].Qty;
Result.Description := Source[Index].Text;
end;
var
Item: TLineItem;
I: Integer;
begin
for I := 0 to High(Source) do
begin
Item := GetLineItem(I); // second pass onward: leaks the prior Description
Log.Add(Item.Description);
end;
end;
למה הדליפה מתחילה רק בקריאה השנייה?
הקריאה הראשונה בלולאה תמיד לא-מזיקה, וזה בדיוק מה שהופך את הפגם הזה לקל לפספוס בבדיקה. משתנה מקומי מסוג-רשומה מנוהל מתחיל באפס, ושום דבר לא מאפס אותו מחדש אוטומטית בין מעבר-לולאה אחד לבא, כך שבפעם הראשונה שלולאה מציבה את ערך-ההחזרה של פונקציה לתוך המשתנה ההוא, השדה Description או ContentsText שלו עדיין nil. FillChar כותבת מעל nil עם אפס, מה שלא משנה כלום מבחינת ספירת ההפניות, והקריאה חוזרת נראית נכונה לגמרי. הקריאה השנייה שונה: אותו משתנה מקומי כבר מחזיק מה שהקריאה הראשונה כתבה לתוכו, וה-Result של הקריאה החדשה נכתב ישירות לתוך אותו אחסון במקום לתוך זיכרון טרי, ריק. FillChar בראש הקריאה השנייה ההיא מאפסת שדה שכבר לא nil, וכל מה שבמורד-הזרם מדפוס-הבייט ההוא שגוי בשקט מאז ואילך. בדיקה שקוראת לפונקציה פעם אחת ובוחנת את התוצאה לעולם לא תראה את הבעיה; רק לולאה, או כל נתיב קוד שקורא לפונקציה שוב ושוב מול אותו יעד, חושף אותה
דליפה אמיתית: הערות, סימניות, ורשומות קישור
PDFiumPas שילחה בדיוק את הפגם הזה לפני גרסה 1.56.4, בשלוש פונקציות שכל אחת מחזירה רשומה שמחזיקה לפחות שדה מנוהל אחד: קורא ההערות ברמת-עמוד מחזיר TPdfAnnotation שנושאת מחרוזות ContentsText ו-AuthorText, קורא הסימניות מחזיר TBookmark שנושא מחרוזת Title, וקורא הערת-הקישור מחזיר TLinkAnnotation שנושא מחרוזת ActionPath ומערך דינמי Points. כל שלושת נפתחו עם אותה צורה המוצגת להלן: ניקוי Result עם FillChar גולמי, ואז מילוי השדות אחד בכל פעם מנתוני העמוד הבסיסיים. מעבר על כל הערה בעמוד אחת בכל פעם, הדרך הרגילה לבנות רשימת ביקורת או פאנל סקירה, קרא לקורא ההערות בלולאה ודלף את הטקסט של ההערה הקודמת בכל מעבר אחרי הראשון; PDF שנוצר במספר חריג גדול של הערות נושאות-טקסט יכול היה לגדל את הזיכרון של תהליך שרץ ארוך כל עוד התהליך ההוא המשיך לרוץ. התיקון נגע בשורה אחת בכל פונקציה: החלפת FillChar(Result, SizeOf(Result), 0) ב-Result := Default(TPdfAnnotation) הספיקה, משום שהצבת Default לרשומה מנוהלת מריצה את רצף השחרור-ואז-ניקוי הרגיל של המהדר במקום מילוי-זיכרון גולמי
function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
Annotation: FPDF_ANNOTATION;
ContentLength: LongWord;
begin
Annotation := FPDFPage_GetAnnot(Page, Index);
FillChar(Result, SizeOf(Result), 0); // clears bytes, not a live reference
Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
ContentLength := FPDFAnnot_GetStringValue(Annotation,
FPDFANNOT_TEXTTYPE_Contents, nil, 0);
if ContentLength >= 4 then
begin
SetLength(Result.ContentsText, ContentLength div 2 - 1);
FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
Pointer(Result.ContentsText), ContentLength);
end;
end;
אותה סכנה מאחורי פרמטר var
קורא הסימניות מציג גרסה עדינה יותר של אותה בעיה, משום שהרשומה שמתנקה עם FillChar אינה ה-Result של הפונקציה עצמה אלא פרמטר var קריאה אחת למטה. SetBookmarkData מקבלת את הפלט שלה כ-var Data: TBookmark ונהגה לנקות את Data בראש הגוף שלה עם FillChar; GetBookmark, הפונקציה הציבורית שבפועל מחזירה TBookmark, קוראת ל-SetBookmarkData ומעבירה את ה-Result שלה עצמה ישר כארגומנט ה-var ההוא. פרמטר var נמסר לפי הפניה, כך ש-Data בתוך SetBookmarkData ו-Result בתוך GetBookmark הם אותו אחסון תחת שני שמות, וכל סיכון-כינוי (aliasing) שחל על ה-Result של פונקציה עצמה חל באותה מידה ישירה על כל פונקציית עזר שמקבלת אותו לפי הפניה. סקירה רק של הפונקציות שמצהירות במילולית סוג-החזרה של רשומה מפספסת את הצורה הזו; החיפוש חייב לעקוב אחר כל פרמטר var ו-out שה-Result מועבר הלאה לתוכו גם כן
procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
BufferSize: LongWord;
begin
Data := Default(TBookmark); // fixed: was FillChar(Data, SizeOf(Data), 0)
Data.Handle := Bookmark;
if Bookmark <> nil then
begin
BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
if BufferSize >= 4 then
begin
SetLength(Data.Title, BufferSize div 2 - 1);
FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
end;
end;
end;
function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
CheckActive;
SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;
מתי FillChar עדיין הבחירה הנכונה?
FillChar עדיין נכונה, ולעיתים קרובות מעט זולה יותר, עבור רשומה שבנויה כולה מסודרים (ordinals), שדות נקודה-צפה, מערכים בגודל-קבוע של אלה, או רשומות פשוטות אחרות עשויות מאותו הדבר, משום שאין בה כלום עבור המהדר לסיים (finalize). סוג המלבן של PDFiumPas עצמה הוא בדיוק המקרה הזה: TPdfRectangle מחזיק ארבעה שדות Double ושום דבר אחר, וניקוי אחד עם FillChar לא משחרר כלום משום שאין כלום ספור-הפניות לשחרר. הבדיקה שמפרידה בין שני המקרים פשוטה לניסוח: האם לאיזה שדה של הרשומה, בכל עומק-קינון, יש סוג string, AnsiString, WideString, מערך דינמי, ממשק, או Variant? רשומה יכולה להיראות מספרית לחלוטין ברמה העליונה ועדיין להיכשל בבדיקה הזו אם אחד השדות שלה הוא עצמו רשומה שקוברת מחרוזת כמה שכבות למטה, כך שהבדיקה חייבת לעקוב אחר רשומות מקוננות כל הדרך ולא להיעצר ברשימת השדות החיצונית ביותר. ביקורת בסיס-קוד קיים עבור התבנית הזו מכנית ולא ממצה: חפש כל קריאת FillChar שהיעד שלה הוא משתנה-רשומה, ואז בדוק את רשימת השדות של הרשומה ההיא מול רשימת הסוגים-המנוהלים לעיל. ביקורת ה-v1.56.4 של PDFiumPas עצמה הריצה בדיוק את החיפוש הזה על פני כל הספרייה ומצאה את החשיפה הזו ביחידה אחת; כל אתר קריאת FillChar אחר כבר ניקה רשומה מספרית פשוטה, שם FillChar הייתה, ונשארת, הכלי הנכון
אותה התנהגות מהדר שהופכת Result מנוצל-מחדש למסוכן כאן גם מניעה משפחה קשורה של אי-הסכמות Delphi-מול-FPC במקום אחר בבסיס הקוד הזה; מאמר נלווה על מכשולי מהדר-חוצה מכסה מקרה שבו FPC ו-Delphi לא מסכימים בדיוק מתי משתנה-זמני של תוצאת-רשומה מסתיים בתוך ביטוי בודד, סימפטום שונה של אותה עובדה בסיסית שה-Result הרשומתי של פונקציה לא תמיד האחסון הטרי, הפרטי שהוא נראה. לולאת ההערות שמשמשת כדוגמה הרצה לאורך המאמר הזה גם היא לא תיאורטית: היא אותו מעבר עמוד-אחר-עמוד שהיית כותב בזמן בניית פאנל סקירת הערות, שזו בדיוק צורת הקוד שהפכה FillChar בן-שורה-אחת לדליפת זיכרון איטית מלכתחילה
שום דבר מזה לא דורש החלפת ספריות או מרדף אחרי באג בקוד מקומפל של מישהו אחר: זו תכונה של שפת Object Pascal עצמה, שכל מפתח Delphi ו-FPC עובד איתה יומיומית, והתיקון הוא קריאת-פונקציה בודדת ברגע שאתה יודע לחפש אותה. ה-API-ים של הערה, סימנייה, והערת-קישור המתוארים כאן נשלחים כחלק מרכיב PDFium עבור Delphi, C++Builder, ו-Lazarus/FPC, לצד שאר משטח הקריאה, העיבוד, וההערות של PDF המכוסה במקום אחר בבלוג הזה