לחיצה על Cancel במהלך עבודת הדפסה במציג רכיב PDFium לפעמים לא עשתה כלום: הלולאה המשיכה לעבד כל עמוד ועותק שנשארו, והעבודה עדיין הגיעה למדפסת. הסיבה הייתה לולאת ה-for של Free Pascal, שקובעת את הגבול העליון שלה פעם אחת בכניסה ללולאה, כך שאיפוס המשתנה מאחורי CopyCount או ToPage באמצע-לולאה לא שינה כלום שכבר רץ. באג-גבולות-הלולאה-של-for הוא המכשול החמישי שיצא מאותה ביקורת תאימות Delphi/FPC של PDFiumPas שהפיקה ארבע מלכודות חוצות-מהדר נוספות, ובניגוד לארבעת אלה הוא חי לגמרי בתוך שגרת ההדפסה של מציג — נדרשה בודקת שהחזיקה Cancel לחוץ לאורך עבודה ארוכה כדי בפועל לשים לב שהמדפסת אף פעם לא נעצרה
למה עבודת ההדפסה ממשיכה אחרי שלוחצים על Cancel?
עבודת ההדפסה המשיכה משום שבדיקת הביטול רק איפסה את המשתנים שהזינו את גבולות הלולאה, לא את הלולאות עצמן, ש-Free Pascal כבר נעלה ברגע שכל לולאה התחילה. ה-handler SpeedButtonPrintClick מאחורי כפתור ה-Print בהדגמות PDFViewer ו-MultiPageViewer בונה כל עבודה משלוש לולאות מקוננות: לולאה חיצונית על פני קבוצות עותקים מקובצות, לולאה אמצעית על פני עמודים, ולולאה פנימית על פני עותקים בלתי-מקובצים של אותו עמוד. PrintDialog.Collate מחליטה איזה מונה, CollateCopyCount או CopyCount, בפועל מחזיק את מספר העותקים המבוקש, בעוד האחר נשאר על אחד. ביטול חייב להגיע דרך שלושת הרמות בבת אחת, וגרסת הקוד הראשונה הזו ניסתה לעשות את זה על ידי איפוס משתני-הגבול עצמם ברגע ש-Cancel הפך true
for CollateCopy:= 1 to CollateCopyCount do
for Page:= FromPage to ToPage do
for Copy:= 1 to CopyCount do
begin
// ... render the page and send it to the printer ...
Application.ProcessMessages;
if Cancel then
begin
CollateCopyCount:= 0;
ToPage:= 0;
CopyCount:= 0;
end;
end;
Printer.EndDoc; // runs whether or not Cancel fired
הכוונה ברורה למדי: אם המונים שמגדירים כמה קיבוצים, עמודים, ועותקים נשארים כולם יורדים לאפס, הלולאות אמורות להיגמר מעבודה ולעבור-דרך בעצמן. Printer.EndDoc אז רצה ללא-תנאי אחרי הלולאה ללא קשר איך היא הסתיימה, כך שאפילו עבודה שמשתמש האמין שהוא עצר עדיין הוגשה ל-spooler עם כל עמוד מעובד לפני שהלחיצה נשמה
מה Free Pascal נועלת כשלולאת for מתחילה
Free Pascal מעריכה את הערך הסופי של לולאת for בדיוק פעם אחת, ברגע שהלולאה מתחילה, ואף פעם לא שוב למשך חיי הלולאה ההיא. for Page := FromPage to ToPage do קוראת ToPage פעם אחת בודדת כדי לחשב כמה איטרציות להריץ, ואחרי זה ללולאה אין יותר עניין במשתנה בשם ToPage, רק בספירת האיטרציה שהיא כבר לכדה. הגדרת ToPage := 0 מתוך גוף הלולאה משנה משתנה שהלולאה הרצה כבר לא מתייעצת בו, וזו בדיוק הסיבה ש-Cancel יכולה להיות true בעוד המדפסת ממשיכה לקבל עמודים לאורך עוד כמה איטרציות, לפעמים כולן
התבנית הזו הרגל סביר לגמרי לשאת מ-C או C++, והדגמת המציג C++Builder של רכיב PDFium משקפת את זו של Pascal קרוב מספיק כדי להפוך את ההשוואה ישירה. for (Copy = 1; Copy <= CopyCount; Copy++) שלה בודקת מחדש Copy <= CopyCount מול הערך החי של CopyCount בכל מעבר, כך שאיפוס המונה שם באמת מסיים את הלולאה בבדיקה הבאה. הדגמת ה-C++Builder נשאה את אותו קוד איפוס-המונים בדיוק וזה עבד, וזה בדיוק מה שגרם לאותו רעיון להיראות בטוח לשימוש חוזר בבנייה של Pascal שיושבת ממש לידה באותו מאגר
למה בניית Delphi לא פגעה באותו באג?
הדגמת PDFViewer של Delphi אף פעם לא הייתה תלויה בכך שלולאה תשים לב לגבול שהשתנה, משום שנתיב הביטול שלה נפרם דרך חריגה במקום השוואה. הלולאה הפנימית ביותר שלה קוראת לפונקציית ה-RTL Abort ברגע ש-Cancel הופך true, שמעלה EAbort שקטה שמתפשטת ישר דרך כל שלוש לולאות ה-for המקוננות אל handler שעוטף את כל בלוק ההדפסה
Printer.BeginDoc;
try
for CollateCopy:= 1 to CollateCopyCount do
for Page:= FromPage to ToPage do
for Copy:= 1 to CopyCount do
begin
// ... render the page and send it to the printer ...
Application.ProcessMessages;
if Cancel then
Abort; // raises EAbort, unwinds all three loops at once
end;
Printer.EndDoc;
except
on E: EAbort do
Printer.Abort;
else
begin
Printer.Abort;
raise;
end;
end;
לחריגה לא אכפת כמה לולאות for מפרידות בין הנקודה שבה היא מועלית לבין ה-handler שלוכד אותה, וזו בדיוק התכונה שהבעיה הזו זקוקה לה. החוסן הזה לא היה הגנה מכוונת מפני התנהגות-נעילת-הגבול המתוארת לעיל — מחבר הדגמת Delphi פשוט פנה לכלי שונה. בריחת-החריגה עדיין שווה למנות כתבנית האיתנה יותר: היא שורדת רמת קינון רביעית שנוספת מאוחר יותר, בעוד שרשרת של הוראות Break ממוקמות ידנית חייבת להיזכר ולהיווסף מחדש בכל פעם שקינון הלולאה משתנה
התיקון: Break בכל רמת קינון, מוגן על ידי דגל PrintSucceeded
התיקון שיצא ב-PDFiumPas v2.27.0 שומר על מבנה שלוש-הלולאות בהדגמות המציג של Lazarus ו-C++Builder אבל הופך ביטול למפורש בכל רמה, ומפריד את עצירת-הלולאה מהגשת-העבודה לתוך דגל שנקרא רק אחרי שהלולאה סיימה לגמרי
Printer.BeginDoc;
try
Cancel:= False;
for CollateCopy:= 1 to CollateCopyCount do
begin
for Page:= FromPage to ToPage do
begin
for Copy:= 1 to CopyCount do
begin
// ... render the page and send it to the printer ...
Application.ProcessMessages;
if Cancel then
Break;
end;
if Cancel then
Break;
end;
if Cancel then
Break;
end;
PrintSucceeded:= not Cancel;
finally
if PrintSucceeded then
Printer.EndDoc
else
Printer.Abort;
end;
PrintSucceeded מחושב במכוון פעם אחת, מיד אחרי שהלולאה המקוננת-משולשת יוצאת, משום כלום מלבד not Cancel. שום דבר בתוך גוף הלולאה לא מקבל לבד את ההחלטה אם העבודה הצליחה — הלולאה יכולה להסתיים רק בשתי דרכים, גמר-עבודה של קיבוצים, עמודים, ועותקים, או פגיעה בשרשרת ה-Break ש-Cancel מפעילה, ו-PrintSucceeded קוראת את התוצאה אחרי-המעשה במקום לעקוב אחריה תוך כדי שהלולאה מתקדמת. חישוב שלה בדרך הזו הוא מה שהופך את הבחירה של בלוק ה-finally בין Printer.EndDoc ל-Printer.Abort לאמינה: היא אף פעם לא מופעלת לפני שהלולאה בפועל התיישבה
ביקורת לולאות הדפסה מונעות-ביטול משלך
שלוש בדיקות נוסעות הרבה מעבר לשגרת ההדפסה הבודדת הזו. לעולם אל תניח שלולאת for של Pascal תשים לב למשתנה-גבול שמשתנה אחרי שהיא התחילה; אם לולאה צריכה להסתיים מוקדם, אמור זאת ישירות עם Break, בכל רמת קינון שהביטול צריך לחצות, לא רק הפנימית ביותר. העדף מנגנון בריחה שנפרם מבנייתו, כמו חריגה, ברגע שהקינון עמוק מספיק כדי ש-Break שהוחמץ יהיה סביר — צמד ה-Abort/EAbort של הדגמת Delphi קיבל את זה בחינם. הגן כל שלב-ביצוע כמו EndDoc מאחורי דגל שמחושב אך ורק אחרי הלולאה, אף פעם לא בתוכה, כך שעבודה שנעצרה מוקדם אף פעם לא יכולה להתבלבל עם אחת שסיימה
אותה סקירה שתיקנה את הלולאה הזו גם חיזקה איך תשע הדגמות המציג מטפלות במקלדת בעוד כפתור-ביטול גלוי: הן עכשיו בולעות כל מקש חוץ מ-Esc, כך ש-Ctrl+P או Ctrl+F תועים במהלך הדפסה או חיפוש פעיל כבר לא יכולים להתחיל שני מעליו. תיקון הכניסה-החוזרת (reentrancy) הזה הוא באג שונה עם מנגנון שונה, אבל הוא יצא מאותה סקירה של מה ש-Cancel בפועל עושה באמצע-פעולה. ההרגל ששווה לקחת משני התיקונים הוא הרגל-בדיקה יותר מהרגל-כתיבה: הפעל ביטול על כל מהדר ששגרת הדפסה משותפת בפועל נשלחת אליו, משום שרעיון כמו ניקוי המונים ומתן אמון בלולאה לשים לב יכול לשרוד בדיקה תחת C++Builder, להגיע לבנייה של Free Pascal בלתי-מאומתת, להיקרא זהה במקור, ולהיכשל בדרך שאף אחד לא רואה עד שמישהו מחזיק Cancel לחוץ מספיק זמן כדי לראות את העבודה מסתיימת בכל זאת. עבור הגדרת ההדפסה שהלוגיקת-ביטול הזו יושבת מעליה, הסיור על הדפסת מסמכי PDF עם רכיב PDFium VCL מכסה את השאר
תיקון-ביטול-ההדפסה הזה נשלח כחלק מרכיב PDFium עבור Delphi, C++Builder, ו-FPC/Lazarus, שמריץ את אותה חבילת הדגמות על פני שלושת המהדרים בכל שחרור כך שפערים כמו זה נתפסים על ידי מטריצת-בנייה ולא כרטיס תמיכה