HotXLS สามารถทำให้ worker thread ของ Delphi crash ได้โดยไม่มี exception ที่ดักจับได้เลย เมื่อมันคำนวณ checksum ของ XML part ของเวิร์กชีตขนาดใหญ่ในการเรียกครั้งเดียว zlib-ng สลับไปใช้อัลกอริทึม Chorba เมื่ออินพุตเกินประมาณ 119 KB และตัวแปร generic-C ของอัลกอริทึมนั้นจัดสรร scratch array ที่ใหญ่พอจะระเบิด stack เริ่มต้นขนาด 1 MB ของ thread Delphi ไม่มีโอกาสตอบสนองเลย เพราะ stack overflow ไม่ใช่ชนิด exception ที่ try/except ถูกสร้างมาให้ดักจับได้
HotXLS เป็นไลบรารีเนทีฟสำหรับ Delphi และ C++Builder สำหรับอ่านและเขียน workbook ของ Excel และการ crash นี้สาวไปถึงตัวเขียนเวิร์กชีตของมัน สัญญาณแรกของปัญหาคือ support ticket งาน export ข้ามคืนล่มประมาณสองครั้งต่อสัปดาห์ ล่มกลางการทำงานเสมอ ไม่มี dialog exception ของ Delphi และไม่มี error ที่ log ไว้ มีแค่โปรเซสที่หายไปและ entry ของ Windows Error Reporting ที่ไม่ชี้ไปที่อะไรที่มีประโยชน์เลย การทำให้เกิดซ้ำที่โต๊ะทำงานเป็นอีกเรื่องหนึ่งโดยสิ้นเชิง workbook เล็กบันทึกได้ดี workbook ใหญ่ก็บันทึกได้ดีเช่นกัน ตราบใดที่การบันทึกนั้นรันบน main thread โดยมี debugger ต่ออยู่แล้ว ต้องใช้ batch จริงของไฟล์ขนาด production ที่วิ่งผ่านเส้นทาง export แบบ multi-threaded จริงถึงจะทำให้ crash นี้ปรากฏขึ้นที่บ้าน ซึ่งตอนนั้น disk I/O, แรงกดดันหน่วยความจำ และเทมเพลตที่ต้องสงสัยต่างถูกตัดออกไปหมดแล้ว
การบันทึกเวิร์กชีตกลายเป็นการเรียก CRC32 ยักษ์ครั้งเดียวได้อย่างไร
ไฟล์ XLSX เป็น ZIP container และรูปแบบ ZIP ต้องการ checksum CRC-32 สำหรับทุก entry บันทึกไว้ทั้งใน local file header และ central directory HotXLS คำนวณ checksum นั้นด้วยการเรียก wrapper เล็กๆ ชื่อ ZLibCRC32 ซึ่งเรียกรูทีน crc32 ของ zlib-ng เองต่อ เมื่อ SaveAs ประกอบ XML ของเวิร์กชีตในหน่วยความจำเสร็จแล้ว และเป็นเวลานานที่การเรียกนั้นถือ buffer ที่ไม่ได้บีบอัดทั้งหมดในการเรียกครั้งเดียว นั่นเป็นการออกแบบที่สมเหตุสมผลสำหรับเวิร์กชีตเล็ก มันกลายเป็นการเรียกที่ใหญ่มากทันทีที่ชีตเป็นชนิดที่ครอบคลุมในคู่มือประสิทธิภาพ workbook ขนาดใหญ่ของ HotXLS ที่ XML ของชีตเดียวมักวิ่งเกินไม่กี่ร้อยกิโลไบต์ก่อนที่มันจะถูกบีบอัดด้วยซ้ำ
ทำไม zlib-ng ถึงต้องการ stack buffer ขนาดยักษ์สำหรับ CRC32
zlib-ng ไม่ได้ใช้ implementation CRC-32 เดียวสำหรับทุกการเรียก ต่ำกว่า threshold ขนาดหนึ่ง มันเดินผ่าน buffer ด้วย table lookup และกลเม็ด folding ที่ไม่ต้องการหน่วยความจำเพิ่มที่มีนัยสำคัญ และเหนือ threshold นั้น ประมาณ 119 KB แม่นตรงที่ 118,960 ไบต์ใน build ที่ HotXLS link ด้วย มันสลับไปใช้อัลกอริทึมเร็วเฉพาะทางชื่อ Chorba implementation แบบ generic-C ของเส้นทางนั้นแลกหน่วยความจำกับความเร็ว มันจัดสรร scratch array บน stack แทนที่จะเป็น heap ขนาดที่ทำให้ inner loop ของอัลกอริทึมเร็ว ไม่ใช่ให้พอดีกับงบ stack ใดก็ตามที่ thread ที่เรียกมันบังเอิญพกมา ไม่มีสิ่งใดในนี้ที่มองเห็นได้จากฝั่งผู้เรียกเลย ฟังก์ชัน checksum ปกติเป็น leaf call อ่านไบต์บางส่วน คืนตัวเลข ไม่มีการจัดสรรที่ควรพูดถึง และสมมติฐานนั้นเป็นจริงสำหรับการเรียกส่วนใหญ่ล้นหลามเข้าไปใน zlib-ng จนกว่า buffer ที่ใหญ่พอจะข้าม threshold ของ 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;
ทำไม worker thread ถึงเจอมัน แต่การ debug แบบ interactive ไม่เคยเจอ
การกระตุ้น crash นี้ต้องการสองเงื่อนไขพร้อมกัน คือ XML part ของเวิร์กชีตที่ใหญ่พอจะข้าม threshold Chorba ของ zlib-ng และ thread ที่มีแค่ stack เริ่มต้นปกติแทนที่จะเป็นอะไรที่กว้างขวางกว่า งาน export ของ production เจอทั้งสองอย่าง พวกมันรันเป็นงาน batch ฝั่งเซิร์ฟเวอร์ที่กระจายการเขียนของ HotXLS ข้าม pool ของ worker thread แต่ละตัวพก stack เริ่มต้น 1 MB ที่ Windows สำรองไว้เว้นแต่ผู้เรียกจะขอมากกว่านั้น และแต่ละตัวประมวลผล workbook ของลูกค้าที่ใหญ่พอจะสำคัญ การ debug ที่โต๊ะทำงานไม่เจอทั้งสองเงื่อนไขอย่างสม่ำเสมอเลย ไฟล์ตัวอย่างมักเล็กกว่า threshold และการรันแบบ single-step มักเกิดบน main thread แทนที่จะอยู่ภายใน worker ที่เพิ่ง spawn ใหม่ ดังนั้นสองเงื่อนไขที่ต้องเรียงกันใน production จึงแทบไม่เคยเรียงกันที่โต๊ะของนักพัฒนาเลย
การไล่ตาม crash ที่โทษฟังก์ชันผิด
รายงาน crash ที่ทีมได้มาชี้ไปยังตำแหน่งภายในฟังก์ชัน deflate ของ zlib-ng ไม่ใช่โค้ดของ HotXLS ใดๆ และไม่ได้ชี้ไปที่โค้ด CRC-32 อย่างชัดเจนด้วยซ้ำ รายละเอียดตัวเดียวนั้นส่งรอบแรกของการสืบสวนไปยังเส้นทางการบีบอัด ขนาด buffer ที่ส่งเข้า deflate, window bits, ระดับการบีบอัด ผู้ต้องสงสัยตามปกติทั้งหมดสำหรับ crash แบบเนทีฟที่มาจาก codec ไม่มีอันไหนยืนหยัดได้เลย
เฟรมบนสุดที่ทำให้เข้าใจผิด
stack overflow เป็น crash ประเภทแปลกที่จะ symbolize เพราะเมื่อถึงเวลาที่มันถูกรายงาน stack pointer วิ่งเลยพื้นที่ที่ถูกสำรองไว้สำหรับมันไปแล้ว อะไรก็ตามที่สร้างรายงาน crash นั้นน่าจะ resolve address ที่ผิดพลาดไปยัง symbol ที่ใกล้ที่สุดที่มันยังหาเจอ และจุดเข้า export ที่ใกล้ที่สุดที่นั่งอยู่ข้างต้นเหตุตัวจริงบังเอิญเป็น deflate จุดผิดพลาดจริงอยู่ในการจัดสรร scratch-buffer ของ Chorba ภายในเส้นทาง CRC-32 ที่ compile เข้าไลบรารีเดียวกัน ใกล้พอใน binary ที่จะถูกเข้าใจผิดว่าเป็นฟังก์ชันที่กำลังรันจริง
การ bisect ด้วย timestamp แทน debugger
crash ที่ทำให้ทั้งโปรเซสล่มไม่เหลืออะไรให้ session debugger ปกติของ Delphi ดักจับได้เลย ดังนั้นทีมจึงหันไปใช้ checkpoint GetTickCount ที่วางไว้รอบทุกการเรียกที่ต้องสงสัย และการ bisect ด้วยมือข้ามเส้นทางการบันทึก แคบลงไปว่าการดำเนินการไหนกำลังทำงานอยู่ ณ ขณะที่โปรเซสตาย ควบคู่ไปกับนั้น build baseline ที่รู้ว่าดีอยู่แล้วรันไฟล์ production เดียวกันควบคู่ไปกับ build ปัจจุบัน โดยเฉพาะเพื่อตัด regression ในการเปลี่ยนแปลงของรอบนั้นเองออกไปก่อนที่จะมองย้อนขึ้นไปไกลกว่านั้น มีแค่หลังจากทั้งสองการตรวจสอบกลับมาสะอาดแล้วเท่านั้น การสืบสวนจึงลงเอยที่ dependency จากบุคคลที่สามที่ทำอะไรบางอย่างที่ไม่คาดคิดกับอินพุตที่ถูกต้องสมบูรณ์แบบ
ทำไม try/except ถึงดักจับ stack overflow ไม่ได้
stack overflow ไม่ใช่ exception ที่โค้ด Delphi ยกขึ้นเองโดยตั้งใจเลย และไม่ได้ถูกส่งมอบแบบเดียวกับที่ Windows ส่งมอบ access violation หรือการหารด้วยศูนย์ด้วย มันปรากฏเป็น hardware guard-page fault รายงานผ่านกลไก structured exception handling เดียวกับที่ try/except ของ Delphi สร้างขึ้นมาบน แต่ ณ ขณะที่มันยิงพอดี ปกติไม่มีพื้นที่ stack เหลือให้รัน handler, คลาย cleanup code หรือแม้แต่รายงานความผิดพลาดให้จบอย่างสะอาดเลย บน worker thread ที่พกแค่การสำรอง 1 MB เริ่มต้น พร้อม scratch buffer ขนาดนั้นที่กินสิ่งที่เหลือไปเกือบหมดแล้ว ไม่มีอะไรเหลือให้ runtime ทำงานด้วยเลย
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;
block except นั้นดูเหมือนตาข่ายนิรภัย และเทียบกับความล้มเหลวส่วนใหญ่มันก็เป็นแบบนั้น แต่มันไม่ทำอะไรเลยตรงนี้ ทีมยืนยันสิ่งนี้ในทางปฏิบัติ try/except ไม่จับอะไรเลย block finally ก็ไม่เคยได้โอกาสที่เชื่อถือได้ในการรันเลยเช่นกัน และผู้ปฏิบัติงานเห็นโปรเซสที่ตายแล้วโดยไม่มี log entry ระดับแอปพลิเคชันเลยแม้แต่น้อย ตรงกับที่ support ticket เดิมอธิบายไว้เป๊ะ
ทางแก้: ป้อน CRC32 เป็นชิ้น 64 KB แทนการเรียกยักษ์ครั้งเดียว
ทางแก้ที่ HotXLS ส่งออกมาไม่เปลี่ยนอะไรเลยเกี่ยวกับ zlib-ng เองและไม่เปลี่ยนอะไรเกี่ยวกับระดับการบีบอัดที่ใช้เขียน workbook ตอนนี้ ZLibCRC32 เดินผ่านอินพุตเป็นชิ้นขนาดคงที่ 64 KB คือ 65536 ไบต์ต่อชิ้น เรียก crc32 ของ zlib-ng หนึ่งครั้งต่อชิ้น และร้อยค่า checksum ที่กำลังทำงานอยู่จากการเรียกหนึ่งไปยังการเรียกถัดไป CRC-32 เป็นอัลกอริทึมแบบ incremental โดยโครงสร้าง ดังนั้น checksum ที่สร้างขึ้นทีละหลายชิ้นจึงเหมือนกันทุกบิตกับที่คำนวณในการเรียกครั้งเดียวข้ามไบต์เดียวกัน ทางแก้เปลี่ยนวิธีแบ่งงาน ไม่ใช่สิ่งที่มันคำนวณ
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 รอบข้างที่ต้องเปลี่ยนเลยเพื่อให้สิ่งนี้ทำงาน และไม่มีอะไรเกี่ยวกับ entry ZIP ที่ HotXLS เขียนที่เปลี่ยนไปเช่นกัน ค่า CRC-32 ที่ลงเอยใน local file header และ central directory เป็นค่าเดียวกันเป๊ะกับที่การเรียกยักษ์ครั้งเดียวจะสร้างขึ้น เพียงแต่ประกอบขึ้นจากชิ้นที่เล็กกว่า การลดระดับ zlib-ng หรือ fallback ไปเป็น implementation CRC-32 ที่ช้ากว่าและใช้การจัดสรรน้อยกว่าก็จะหลีกเลี่ยง crash ได้เช่นกัน แต่ด้วยต้นทุนจริงต่อทุกไฟล์ที่ไม่เคยเข้าใกล้ threshold ตั้งแต่แรกเลย ซึ่งเป็นเหตุผลที่ไม่มีทางเลือกไหนถูกส่งออกมา
สิ่งนี้หมายความว่าอย่างไรถ้าคุณเรียก zlib-ng จาก worker thread ของคุณเอง
โหมดความล้มเหลวแบบ stack overflow ที่อธิบายไว้ตรงนี้ไม่เกี่ยวอะไรกับสเปรดชีตโดยเฉพาะเลย แอปพลิเคชันใดก็ตามที่ส่ง buffer ขนาดใหญ่ให้ zlib-ng ไม่ว่าจะเพื่อการบีบอัด ถอดรหัส หรือ checksum จาก thread ที่พกแค่ stack เริ่มต้นของแพลตฟอร์ม สามารถชนกำแพงประเภทเดียวกันได้ เพราะไลบรารีเลือกอัลกอริทึมของมันตามขนาดอินพุต และอัลกอริทึมบางตัวสมมติว่ามี stack เหลือให้ใช้ การป้องกันสองอย่างทำงานได้โดยไม่ต้องแตะ zlib-ng เอง การป้อน buffer ขนาดใหญ่เข้ารูทีนที่ไวต่อขนาดเป็นชิ้นขนาดคงที่ ลบเงื่อนไขกระตุ้นออกไปทั้งหมดสำหรับอัลกอริทึมใดก็ตามที่เป็น incremental โดยธรรมชาติ และในที่ที่การแบ่งชิ้นไม่ใช่ตัวเลือก การให้ thread ที่เรียกมี stack ใหญ่กว่าค่าเริ่มต้นของแพลตฟอร์มเป็นคันโยกอีกตัว ตัวใดตัวหนึ่งถูกกว่าการค้นพบ threshold ขนาดที่ไม่มีเอกสารประกอบจากรายงาน crash ของ production ที่โทษฟังก์ชันผิด
threshold นี้โดยเฉพาะยังคงมองไม่เห็นจนกว่า workbook production ที่ใหญ่พอจะข้ามมันบน thread ประเภทที่ผิด ซึ่งเป็นความล้มเหลวประเภทที่ปรากฏขึ้นก็ต่อเมื่อโค้ดรันกับไฟล์จริงแทนที่จะเป็น fixture เล็กๆ เท่านั้น เส้นทาง CRC-32 แบบแบ่งชิ้นตอนนี้มาพร้อมกับ write pipeline มาตรฐานในHotXLS Excel Componentสำหรับ Delphi และ C++Builder โดยไม่มีอะไรให้ผู้เรียกตั้งค่าและไม่มี property ให้เปิดหรือปิดมัน