HotXLS יכולה להקריס תהליכון-עבודה ב-Delphi ללא חריגה ניתנת-ללכידה כאשר היא מבצעת סכום ביקורת (checksum) על חלק XML גדול של גיליון עבודה בקריאה אחת: zlib-ng עוברת לאלגוריתם ה-Chorba שלה מעל בערך 119 KB של קלט, והגרסה הגנרית-C של האלגוריתם הזה מקצה מערך-עבודה (scratch array) גדול מספיק כדי לפוצץ את מחסנית תהליכון ברירת-המחדל בגודל 1MB. Delphi אף פעם לא מקבלת הזדמנות להגיב, משום שגלישת מחסנית אינה מהסוג של חריגה ש-try/except נבנה כדי ללכוד
HotXLS היא ספרייה ילידית של Delphi ו-C++Builder לקריאה וכתיבה של חוברות עבודה של Excel, והקריסה הוליכה בחזרה לכותב הגיליון-עבודה שלה. הסימן הראשון לצרה היה כרטיס תמיכה: עבודת ייצוא לילית קרסה בערך פעמיים בשבוע, תמיד באמצע-ריצה, ללא תיבת דו-שיח של חריגת Delphi וללא שגיאה רשומה ביומן, רק תהליך שנעלם ורשומת Windows Error Reporting שלא הצביעה לשום מקום שימושי. שחזור זה בשולחן העבודה היה עניין שונה לגמרי. חוברות עבודה קטנות נשמרו בסדר. חוברות עבודה גדולות גם נשמרו בסדר, כל עוד השמירה רצה על התהליכון הראשי עם דיבאגר כבר מחובר. נדרשה אצווה ממשית של קבצים בגודל-ייצור שרצה דרך נתיב הייצוא הרב-תהליכוני האמיתי כדי להביא את הקריסה הביתה, ובנקודה הזו I/O של הדיסק, לחץ זיכרון, ותבנית חשודה כולם כבר נשללו
איך שמירת גיליון-עבודה הופכת לקריאת CRC32 אחת ענקית
קובצי XLSX הם מכולות ZIP, ופורמט ה-ZIP דורש סכום ביקורת CRC-32 עבור כל רשומה, נרשם הן בכותרת הקובץ המקומית והן במדריך המרכזי. HotXLS מחשבת את סכום הביקורת ההוא על ידי קריאה לעוטף קטן בשם ZLibCRC32, שבתורו קורא לפונקציית ה-crc32 של zlib-ng עצמה ברגע ש-SaveAs מסיימת להרכיב את ה-XML של גיליון עבודה בזיכרון, ולזמן ארוך הקריאה הזו נשאה את כל המאגר הבלתי-דחוס בקריאה אחת. זה עיצוב סביר עבור גיליון-עבודה קטן. הוא הופך לקריאה ענקית מאוד ברגע שגיליון הוא מהסוג המכוסה בהמדריך שלנו לביצועי חוברת-עבודה גדולה ב-HotXLS, שם ה-XML של גיליון בודד באופן שגרתי רץ מעבר לכמה מאות קילו-בייט לפני שהוא אי-פעם נדחס
למה zlib-ng זקוקה למאגר מחסנית ענק עבור CRC32?
zlib-ng לא משתמשת במימוש CRC-32 אחד לכל קריאה. מתחת לסף גודל היא עוברת על המאגר עם חיפושי-טבלה ותחבולות קיפול שלא זקוקות לזיכרון נוסף משמעותי, ומעל הסף ההוא, בערך 119 KB, בדיוק 118,960 בייטים בבנייה ש-HotXLS מקושרת מולה, היא עוברת לאלגוריתם מהיר מיוחד בשם Chorba. המימוש הגנרי-C של הנתיב הזה מחליף זיכרון במהירות: הוא מקצה מערך-עבודה על המחסנית ולא הערימה, בגודל שהופך את הלולאה הפנימית של האלגוריתם למהירה, לא שיתאים בנוחות בתוך איזה תקציב-מחסנית שהתהליכון הקורא במקרה נושא. שום דבר מזה לא נראה מצד הקוד הקורא. פונקציית סכום ביקורת בדרך כלל היא קריאת-עלה (leaf call), קרא כמה בייטים, החזר מספר, אין הקצאה ששווה לדון בה, וההנחה הזו מחזיקה מעמד עבור הרוב המכריע של הקריאות לתוך zlib-ng ממש עד שמאגר גדול מספיק כדי לחצות את סף ה-Chorba נכנס לאחת
function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
// One call over the whole worksheet XML buffer: fine for a small
// sheet, but a large enough input pushes zlib-ng onto its Chorba
// fast path and that path's stack-hungry scratch buffer
Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;
למה תהליכוני-עבודה ראו את זה וניפוי-שגיאות אינטראקטיבי אף פעם לא
הפעלת הקריסה הזו דורשת שני תנאים בו-זמנית: חלק XML של גיליון-עבודה גדול מספיק כדי לחצות את סף ה-Chorba של zlib-ng, ותהליכון שיש לו רק את מחסנית ברירת-המחדל הרגילה במקום משהו מרווח יותר. עבודות ייצוא ייצור פוגעות בשניהם. הן רצות כעבודות אצווה בצד-שרת שמפזרות כתיבות HotXLS על פני מאגר תהליכוני-עבודה, כל אחד נושא את מחסנית ברירת-המחדל בגודל 1MB ש-Windows שומרת אלא אם קוד קורא מבקש יותר, וכל אחד מעבד חוברות עבודה של לקוח גדולות מספיק כדי שזה יהיה חשוב. ניפוי-שגיאות בשולחן העבודה לא פגע באף אחד מהתנאים באופן אמין: קבצי דוגמה בדרך כלל היו קטנים מהסף, וריצות צעד-אחר-צעד נטו לקרות על התהליכון הראשי במקום בתוך תהליכון-עבודה שנוצר טרי, כך ששני התנאים שהיו צריכים להתיישר בייצור כמעט אף פעם לא התיישרו על שולחן של מפתח
מרדף אחרי קריסה שהאשימה את הפונקציה הלא-נכונה
דוחות הקריסה שהצוות הצליח להשיג הצביעו על מיקום בתוך פונקציית ה-deflate של zlib-ng, לא על שום קוד HotXLS, ולא באופן ברור גם על קוד ה-CRC-32. הפרט הבודד הזה שלח את המעבר הראשון של החקירה לעבר נתיב הדחיסה: גדלי מאגר שנמסרו ל-deflate, סיביות חלון, רמת דחיסה, כל החשודים הרגילים עבור קריסה ילידית שיוצאת מקודק. אף אחד מהם לא החזיק מעמד
מסגרת-עליונה מטעה
גלישת מחסנית היא סוג מוזר של קריסה לסמל, משום שעד שהיא מדווחת, מצביע המחסנית כבר רץ מעבר למרחב ששמור עבורו. מה שייצר את דוח הקריסה ההוא ככל הנראה פתר את הכתובת התוקעת לסמל הקרוב ביותר שהוא עדיין הצליח למצוא, ונקודת הכניסה המיוצאת הקרובה ביותר שיושבת ליד האשם האמיתי במקרה הייתה deflate. התקלה בפועל ישבה בהקצאת מערך-העבודה של Chorba בתוך נתיב ה-CRC-32, מקומפלת לתוך אותה ספרייה, קרובה מספיק בבינארי כדי להתבלבל עם הפונקציה שבאמת רצה
חצייה (bisecting) עם חותמות-זמן במקום דיבאגר
קריסה שמפילה את כל התהליך לא משאירה דבר לסשן דיבאגר רגיל של Delphi ללכוד, כך שהצוות נפל בחזרה על נקודות בדיקה של GetTickCount שהושלכו סביב כל קריאה חשודה וחצייה ידנית על פני נתיב השמירה, מצמצם איזו פעולה הייתה בטיסה ברגע שהתהליך מת. לצד זה, בנייה בסיסית ידועה-כתקינה הריצה את אותם קבצי ייצור זה-לצד-זה עם הבנייה הנוכחית, ספציפית כדי לשלול רגרסיה בשינויים העצמם של הסבב ההוא לפני שמסתכלים הלאה במעלה-הזרם. רק אחרי ששתי הבדיקות חזרו נקיות החקירה התיישבה על תלות צד-שלישי שעושה משהו בלתי-צפוי עם קלט תקף לחלוטין
למה try/except נכשל בלכידת גלישת מחסנית?
גלישת מחסנית אינה חריגה שקוד Delphi אי-פעם מעלה בכוונה, והיא גם לא נמסרת באופן ש-Windows מוסר violation-גישה או חלוקה-באפס. היא צצה כתקלת guard-page חומרתית, מדווחת דרך אותו מנגנון טיפול-חריגות מובנה ש-try/except של Delphi בנוי עליו, אבל ברגע המדויק שהיא מופעלת בדרך כלל אין מרחב מחסנית שנשאר להריץ handler, לפרום קוד ניקוי, או אפילו לסיים לדווח על התקלה בנקיות. על תהליכון-עבודה שנושא רק את השמירה של ברירת המחדל בגודל 1MB, עם מאגר-עבודה בגודל ההוא שכבר צרך את רוב מה שנשאר, אין כלום שנשאר לזמן הריצה לעבוד איתו
procedure TExportWorker.Execute;
var
Workbook: TXLSXWorkbook;
begin
Workbook := TXLSXWorkbook.Create;
try
try
BuildWorksheet(Workbook);
Workbook.SaveAs(FTargetFile); // crashes the process here on a
// large enough sheet: try/except
// never gets a chance to run
except
on E: Exception do
LogError('Export failed: ' + E.Message);
end;
finally
Workbook.Free;
end;
end;
בלוק ה-except הזה נראה כמו רשת ביטחון, ומול רוב הכשלים הוא כזה, אבל הוא לא עושה כלום כאן. הצוות אישר את זה בפועל: try/except לא לכד כלום, בלוק ה-finally גם אף פעם לא קיבל הזדמנות אמינה לרוץ, והמפעיל ראה תהליך מת ללא רשומת יומן ברמת-אפליקציה בכלל, בדיוק מה שכרטיס התמיכה המקורי תיאר
התיקון: הזנת CRC32 בפיסות של 64 KB במקום קריאה ענקית אחת
התיקון ש-HotXLS שילחה לא משנה כלום לגבי zlib-ng עצמה ולא כלום לגבי רמת הדחיסה שמשמשת לכתיבת חוברת העבודה. ZLibCRC32 עכשיו עוברת על הקלט בפיסות קבועות של 64 KB, 65536 בייטים כל אחת, קוראת ל-crc32 של zlib-ng פעם אחת לכל פיסה ומשרשרת את ערך סכום-הביקורת הרץ מקריאה אחת לבאה. CRC-32 הוא אלגוריתם תוספתי (incremental) מבנייתו, כך שסכום ביקורת שנבנה על פני כמה פיסות זהה בייט-לבייט לאחד שחושב בקריאה בודדת על פני אותם בייטים: התיקון משנה איך העבודה מחולקת, לא מה היא מחשבת
function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
// 64 KB keeps every call comfortably under the Chorba threshold
CrcChunkSize = 65536;
var
Cursor: PByte;
ThisChunk: Longint;
begin
Result := crc;
Cursor := PByte(@buffer);
while count > 0 do
begin
ThisChunk := count;
if ThisChunk > CrcChunkSize then
ThisChunk := CrcChunkSize;
Result := zng_crc32(Result, Cursor, Cardinal(ThisChunk));
Inc(Cursor, ThisChunk);
Dec(count, ThisChunk);
end;
end;
שום דבר בקריאת ה-SaveAs שמסביב לא היה צריך להשתנות כדי שזה יעבוד, ושום דבר ברשומות ה-ZIP ש-HotXLS כותבת גם לא השתנה: ערך ה-CRC-32 שמסתיים בכותרת הקובץ המקומית ובמדריך המרכזי הוא בדיוק הערך שקריאה ענקית בודדת הייתה מייצרת, פשוט מורכב מפיסות קטנות יותר. הורדת גרסה של zlib-ng או נפילה בחזרה למימוש CRC-32 איטי יותר, קל-הקצאה, גם היו נמנעים מהקריסה, אבל במחיר אמיתי לכל קובץ שאף פעם לא התקרב לסף מלכתחילה, וזו הסיבה שאף אחד מהם לא יצא לדרך
מה זה אומר אם אתה קורא ל-zlib-ng מתהליכוני-העבודה שלך עצמך
מצב-הכשל של גלישת המחסנית המתואר כאן אין לו שום קשר לגיליונות אלקטרוניים ספציפית. כל אפליקציה שמוסרת ל-zlib-ng מאגר גדול, בין אם עבור דחיסה, פענוח, או סכום ביקורת, מתהליכון שנושא רק את מחסנית ברירת-המחדל של הפלטפורמה יכולה לפגוע באותו סוג קיר, משום שהספרייה בוחרת את האלגוריתם שלה לפי גודל קלט וחלק מהאלגוריתמים האלה מניחים שיש מחסנית פנויה. שתי הגנות עובדות בלי לגעת ב-zlib-ng עצמה: הזנת מאגרים גדולים לתוך פונקציות רגישות-לגודל בפיסות קבועות מסירה את תנאי-ההפעלה לגמרי עבור כל אלגוריתם שהוא תוספתי באופן טבעי, ובמקום שבו חלוקה-לפיסות אינה אופציה, מתן לתהליכון הקורא מחסנית גדולה יותר מברירת המחדל של הפלטפורמה הוא המנוף האחר. כל אחת מהן זולה יותר מלגלות על סף-גודל בלתי-מתועד מדוח קריסת ייצור שמאשים את הפונקציה הלא-נכונה
הסף המסוים הזה נשאר בלתי-נראה עד שחוברת עבודת ייצור גדולה מספיק חצתה אותו על הסוג הלא-נכון של תהליכון, שזה בדיוק הסוג של כשל שרק מופיע ברגע שקוד רץ מול קבצים אמיתיים במקום מתקנים (fixtures) קטנים. נתיב ה-CRC-32 המחולק-לפיסות עכשיו נשלח כחלק מצינור הכתיבה התקני ברכיב ה-Excel HotXLS עבור Delphi ו-C++Builder, ללא שום דבר לקוד קורא להגדיר וללא מאפיין שמפעיל או מכבה אותו