การคลิก Cancel ระหว่างงานพิมพ์ใน viewer ของ PDFium Component บางครั้งไม่ทำอะไรเลย loop ยังคง render ทุกหน้าและสำเนาที่เหลือต่อไป และงานยังคงไปถึงเครื่องพิมพ์ สาเหตุคือ for loop ของ Free Pascal ซึ่งกำหนดขอบเขตบนของมันแค่ครั้งเดียวตอนเข้า loop ดังนั้นการตั้งตัวแปรเบื้องหลัง CopyCount หรือ ToPage เป็นศูนย์กลาง loop จึงไม่เปลี่ยนแปลงอะไรที่กำลังรันอยู่แล้วเลย บั๊กขอบเขต for-loop นี้เป็นข้อผิดพลาดตัวที่ห้าที่ออกมาจากการตรวจสอบความเข้ากันได้ระหว่าง Delphi/FPC ของ PDFiumPas ชุดเดียวกับที่สร้างข้อผิดพลาดข้ามคอมไพเลอร์อีกสี่ตัว และต่างจากสี่ตัวนั้นตรงที่มันอยู่ทั้งหมดภายในรูทีนการพิมพ์ของ viewer ต้องใช้ผู้ทดสอบที่กดค้าง Cancel ตลอดงานยาวๆ ถึงจะสังเกตเห็นว่าเครื่องพิมพ์ไม่เคยหยุดจริงๆ
ทำไมงานพิมพ์ยังคงดำเนินต่อหลังจากคลิก Cancel
งานพิมพ์ยังคงดำเนินต่อเพราะการตรวจสอบการยกเลิกแค่รีเซ็ตตัวแปรที่ป้อนขอบเขตของ loop เท่านั้น ไม่ใช่ตัว loop เอง ซึ่ง Free Pascal ล็อกไว้แล้วตั้งแต่แต่ละ loop เริ่มต้น handler SpeedButtonPrintClick เบื้องหลังปุ่ม Print ในตัวอย่าง PDFViewer และ MultiPageViewer สร้างทุกงานจาก loop ที่ซ้อนกันสามชั้น loop นอกสุดข้ามชุดสำเนาแบบ collate, loop กลางข้ามหน้า และ loop ในสุดข้ามสำเนาแบบไม่ collate ของหน้าเดียวกัน PrintDialog.Collate ตัดสินว่าตัวนับไหน คือ CollateCopyCount หรือ CopyCount ที่ถือจำนวนสำเนาที่ขอไว้จริงๆ ในขณะที่อีกตัวอยู่ที่หนึ่ง การยกเลิกต้องเข้าถึงทั้งสามระดับพร้อมกัน และโค้ดเวอร์ชันแรกของสิ่งนี้พยายามทำแบบนั้นด้วยการรีเซ็ตตัวแปรขอบเขตเองทันทีที่ Cancel กลายเป็นจริง
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
เจตนาอ่านได้ชัดเจนพอสมควร ถ้าตัวนับที่นิยามว่ายังเหลือ collation, หน้า และสำเนากี่ชุดตกลงเป็นศูนย์ทั้งหมด loop ควรหมดงานและตกลงมาเอง Printer.EndDoc จากนั้นรันแบบไม่มีเงื่อนไขหลัง loop ไม่ว่ามันจะจบแบบไหน ดังนั้นแม้แต่งานที่ผู้ใช้เชื่อว่าตัวเองหยุดไปแล้วก็ยังคงถูกส่งไปยัง spooler พร้อมทุกหน้าที่ render ไปแล้วก่อนที่การคลิกจะถูกสังเกตเห็น
Free Pascal ล็อกอะไรไว้เมื่อ for loop เริ่มต้น
Free Pascal ประเมินค่าสุดท้ายของ for loop แค่ครั้งเดียวเป๊ะ ณ ขณะที่ loop เริ่มต้น และไม่เคยอีกเลยตลอดอายุของ loop นั้น for Page := FromPage to ToPage do อ่าน ToPage ครั้งเดียวเพื่อคำนวณว่าต้องรันกี่รอบ และหลังจากนั้น loop ก็ไม่สนใจตัวแปรชื่อ ToPage อีกเลย สนใจแค่จำนวนรอบที่มันจับไว้แล้วเท่านั้น การตั้ง ToPage := 0 จากภายใน body ของ loop เปลี่ยนตัวแปรที่ loop ที่กำลังรันอยู่ไม่ได้ปรึกษาอีกต่อไปแล้ว ซึ่งเป็นเหตุผลตรงๆ ว่าทำไม Cancel ถึงเป็นจริงได้ในขณะที่เครื่องพิมพ์ยังคงรับหน้าต่อไปอีกหลายรอบ บางครั้งก็ครบทุกรอบเลย
รูปแบบนั้นเป็นนิสัยที่สมเหตุสมผลอย่างยิ่งที่จะติดมาจาก C หรือ C++ และตัวอย่าง viewer C++Builder ของ PDFium Component ก็สะท้อนตัว Pascal ใกล้เคียงพอที่จะเปรียบเทียบได้ตรงๆ for (Copy = 1; Copy <= CopyCount; Copy++) ของมันทดสอบ Copy <= CopyCount ใหม่เทียบกับค่าที่มีชีวิตของ CopyCount ทุกรอบ ดังนั้นการตั้งตัวนับให้เป็นศูนย์ตรงนั้นจึงจบ loop ได้จริงในการตรวจสอบครั้งถัดไป ตัวอย่าง C++Builder พกโค้ดตั้งตัวนับเป็นศูนย์แบบเดียวกันและมันทำงานได้ ซึ่งเป็นสิ่งที่ทำให้แนวคิดเดียวกันดูปลอดภัยที่จะใช้ซ้ำใน build Pascal ที่นั่งอยู่ข้างๆ กันพอดีใน repository เดียวกัน
ทำไม build ของ Delphi ถึงไม่เจอบั๊กเดียวกัน
ตัวอย่าง PDFViewer ของ Delphi ไม่เคยพึ่งพา loop ที่สังเกตขอบเขตที่เปลี่ยนไปเลย เพราะเส้นทางการยกเลิกของมันคลายตัวผ่าน exception แทนการเปรียบเทียบ loop ในสุดของมันเรียก procedure Abort ของ RTL ทันทีที่ Cancel กลายเป็นจริง ซึ่งยก EAbort แบบเงียบที่กระจายตรงผ่าน for loop ที่ซ้อนกันทั้งสามไปยัง handler ที่ห่อรอบ block การพิมพ์ทั้งหมด
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;
exception ไม่สนใจเลยว่ามี for loop กี่ตัวคั่นระหว่างจุดที่มันถูกยกขึ้นกับ handler ที่ดักจับมัน ซึ่งเป็นคุณสมบัติพอดีที่ปัญหานี้ต้องการ ความแข็งแรงนั้นไม่ใช่การป้องกันที่จงใจต่อพฤติกรรมการล็อกขอบเขตที่อธิบายไว้ข้างต้น ผู้เขียนตัวอย่าง Delphi แค่เอื้อมไปหยิบเครื่องมือที่ต่างออกไปเท่านั้น ทางออกที่อิง exception ยังคงควรค่าแก่การเรียกว่าเป็นรูปแบบที่แข็งแรงกว่า มันอยู่รอดได้แม้เพิ่มระดับการซ้อนที่สี่เข้ามาในภายหลัง ในขณะที่ chain ของคำสั่ง Break ที่วางด้วยมือต้องถูกจำและเพิ่มใหม่ทุกครั้งที่การซ้อน loop เปลี่ยนไป
ทางแก้: Break ในทุกระดับการซ้อน คุมด้วย flag PrintSucceeded
ทางแก้ที่ส่งออกมาใน PDFiumPas v2.27.0 คงโครงสร้างสามชั้น loop ไว้ในตัวอย่าง viewer ของ Lazarus และ C++Builder แต่ทำให้การยกเลิกชัดเจนในทุกระดับ และแยกการหยุด loop ออกจากงานที่ถูกส่งไปเป็น flag ที่ถูกอ่านก็ต่อเมื่อ loop เสร็จสิ้นสมบูรณ์แล้วเท่านั้น
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 ถูกคำนวณอย่างจงใจแค่ครั้งเดียว ทันทีหลังจาก loop สามชั้นที่ซ้อนกันออกมา จากแค่ not Cancel ไม่มีอะไรภายใน body ของ loop ที่ได้ตัดสินใจเองว่างานสำเร็จหรือไม่ loop จบได้แค่สองทางเท่านั้น คือหมด collation, หน้า และสำเนา หรือชน chain Break ที่ Cancel กระตุ้น และ PrintSucceeded อ่านผลลัพธ์หลังจากนั้น แทนที่จะติดตามมันไปตามที่ loop ดำเนินไป การคำนวณมันแบบนั้นคือสิ่งที่ทำให้การเลือกของ block finally ระหว่าง Printer.EndDoc กับ Printer.Abort เชื่อถือได้ มันไม่เคยยิงก่อนที่ loop จะยุติจริงๆ เลย
การตรวจสอบ loop การพิมพ์ที่ขับเคลื่อนด้วยการยกเลิกของคุณเอง
การตรวจสอบสามอย่างเดินทางไปไกลเกินกว่ารูทีนการพิมพ์ตัวนี้ อย่าสมมติว่า for loop ของ Pascal จะสังเกตเห็นตัวแปรขอบเขตที่เปลี่ยนไปหลังจากที่มันเริ่มต้นแล้ว ถ้า loop ต้องจบก่อนกำหนด ให้บอกมันตรงๆ ด้วย Break ในทุกระดับการซ้อนที่การยกเลิกต้องข้าม ไม่ใช่แค่ระดับในสุด ให้ความสำคัญกับกลไกหลบหนีที่คลายตัวโดยโครงสร้าง เช่น exception เมื่อการซ้อนลึกพอที่ Break ที่พลาดไปจะเป็นไปได้จริง คู่ Abort/EAbort ของตัวอย่าง Delphi ได้สิ่งนี้มาฟรีๆ คุมขั้นตอน commit ใดๆ เช่น EndDoc ไว้หลัง flag ที่คำนวณอย่างเคร่งครัดหลัง loop เท่านั้น ไม่ใช่ภายในมัน เพื่อให้งานที่หยุดก่อนกำหนดไม่มีทางถูกเข้าใจผิดว่าเป็นงานที่เสร็จแล้ว
การ review เดียวกันที่แก้ loop นี้ก็ทำให้วิธีที่ตัวอย่าง viewer ทั้งเก้ารับมือกับคีย์บอร์ดขณะที่ปุ่ม cancel ปรากฏอยู่แน่นขึ้นด้วย ตอนนี้พวกมันกลืนทุกคีย์ยกเว้น Esc ดังนั้น Ctrl+P หรือ Ctrl+F ที่หลงเข้ามาระหว่างการพิมพ์หรือค้นหาที่กำลังทำงานอยู่จะเริ่มอีกครั้งซ้อนทับไม่ได้อีกต่อไป การแก้ปัญหา reentrancy นี้เป็นบั๊กที่ต่างออกไปด้วยกลไกที่ต่างกัน แต่มันออกมาจาก review เดียวกันของสิ่งที่ Cancel ทำจริงๆ กลางการดำเนินการ นิสัยที่ควรนำติดตัวไปจากการแก้ไขทั้งสองเป็นนิสัยการทดสอบมากกว่าการเขียนโค้ด ทดสอบการยกเลิกบนทุกคอมไพเลอร์ที่รูทีนการพิมพ์ที่ใช้ร่วมกันส่งออกไปจริงๆ เพราะแนวคิดอย่างการเคลียร์ตัวนับแล้วเชื่อว่า loop จะสังเกตเห็นสามารถผ่านการทดสอบบน C++Builder ได้ ไปถึง build Free Pascal โดยไม่ได้ตรวจสอบ อ่านเหมือนกันเป๊ะใน source และล้มเหลวในแบบที่ไม่มีใครเห็นจนกว่าจะมีใครกดค้าง Cancel นานพอที่จะดูงานเสร็จไปเฉยๆ สำหรับการตั้งค่าการพิมพ์ที่ logic การยกเลิกนี้อยู่บนคู่มือการพิมพ์เอกสาร PDF ด้วย PDFium VCL component ครอบคลุมส่วนที่เหลือ
การแก้ไขการยกเลิกการพิมพ์นี้มาพร้อมกับPDFium Componentสำหรับ Delphi, C++Builder และ FPC/Lazarus ซึ่งรัน demo suite ชุดเดียวกันข้ามทั้งสามคอมไพเลอร์ในทุก release เพื่อให้ช่องว่างแบบนี้ถูกจับด้วย build matrix แทนที่จะเป็น support ticket