מאמר טכני

באג לולאת for של FPC: לחיצה על ביטול לא עוצרת הדפסה

לחיצה על 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, שמריץ את אותה חבילת הדגמות על פני שלושת המהדרים בכל שחרור כך שפערים כמו זה נתפסים על ידי מטריצת-בנייה ולא כרטיס תמיכה