บทความเทคนิค

วินิจฉัยความล้มเหลวเงียบ ๆ ของ stub ในไลบรารี PDF Pascal

เมื่อไลบรารี Delphi เติม build configuration ที่ไม่มี framework ด้าน visual คลาสแทนที่คือที่ที่บั๊กอาศัยอยู่ ไม่ใช่ platform ไม่ใช่คอมไพเลอร์ แต่คือตัว stand-in PDFlibPas มีชั้นกราฟิกที่ให้สิ่งทดแทน bitmap, canvas, font, metafile และ printer สำหรับบิลด์ที่ไม่มี VCL และการพอร์ตมันไป Free Pascal ทำให้ทุก failure mode ที่ stand-in เป็นได้ผุดขึ้นมาครบถ้วน พวกมันเรียงกันตามต้นทุนการวินิจฉัยอย่างเป็นระเบียบ และลำดับนั้นตรงข้ามกับสัญชาตญาณเสียอีก

stand-in ที่ raise หาง่าย เพราะ exception บอกชื่อเมธอด ตัวที่คืนข้อมูลว่างแพง เพราะอาการปรากฏอยู่ห่างจากสาเหตุออกไปหลายชั้น ตัวที่คืนค่าความสำเร็จเลวร้ายที่สุด เพราะรหัสคืนกลับถูกต้อง รหัส error เป็นศูนย์ ไม่มี exception ถูกปล่อยออกมา และหลักฐานเดียวว่ามีอะไรผิดพลาดอยู่ในไบต์ที่ไหลออกมา

รูปร่างของความล้มเหลวเงียบ ๆ จาก stub ในไลบรารี PDF ภาษา Pascal สามแบบ เรียงตามต้นทุนการวินิจฉัย จาก stub ที่ raise ไปถึงแบบรายงานความสำเร็จทับผลลัพธ์ว่าง
stand-in ที่ raise วินิจฉัยได้ถูก ข้อมูลว่างแพง และการคืนค่าความสำเร็จทับผลิตผลว่างเปล่าคืออันที่หายากที่สุด

รูปร่างที่สาม: image identifier ที่ถูกต้องทับ XObject ที่ว่างเปล่า

ตัวแปลง metafile เวกเตอร์เป็นเพียง body โพรซีเดอร์ว่าง ๆ ใน configuration ที่ไม่มี VCL ทุกอย่างที่อยู่เหนือมันยังทำงานต่อได้ปกติ entry point การนำเข้า EMF และ entry point การ capture จาก canvas วิ่งจนจบและคืน image identifier ที่ถูกกฎหมาย ซึ่งผู้เรียกนำไปวางบนหน้า สิ่งที่ลงไปในไฟล์คือ form XObject ที่มีความยาวเนื้อหาเป็นศูนย์ หน้าแสดงผลเป็นสีขาว

ไม่มีอะไรรายงานปัญหาเลย รวมถึงโปรแกรมสาธิตของไลบรารีเองสำหรับฟีเจอร์นี้ด้วย ซึ่งวาดหน้าว่างออกมาและไม่สังเกตเห็น ไม่มีค่าคืนกลับที่ล้มเหลวให้เช็ก เพราะลำดับการเรียกทั้งหมดสำเร็จจริง ๆ สิ่งที่ผิดมีเพียงขนาดของสตรีมที่ถูกผลิต การวินิจฉัยข้อบกพร่องชั้นนี้ต้องถามคำถามที่ต่างออกไป: ไม่ใช่ "การเรียกล้มเหลวหรือเปล่า" แต่คือ "ผลิตผลน่าเชื่อถือหรือเปล่า" form XObject ที่ยาวศูนย์ ภาพที่มีศูนย์พิกเซล หน้าที่มีศูนย์ไบต์เนื้อหา พวกนี้คือ assertion ที่จับมันได้

ทางแก้มีสองครึ่ง และครึ่งที่สองง่ายต่อการถูกลืม หนึ่ง ทำให้ implementation ว่างเปล่า raise ออกมา เพื่อให้ความล้มเหลวมีช่องทางส่งเสียงสักที สอง แปลง exception นั้นเป็นผลลัพธ์ null ที่โรงงานสร้างภาพ และเติม null check ที่สองจุดที่กิน image identifier เพราะไม่อย่างนั้น "ความล้มเหลวที่สะอาด" จะพุ่งตรงไปเป็น access violation ตอน page tree ไป dereference ค่าว่าง ตัว stub ที่ throw จะเป็นการดีขึ้นก็ต่อเมื่อผู้เรียกพร้อมรับความล้มเหลวที่ก่อนหน้านี้มันไม่เคยได้รับ

รูปร่างที่สอง: ข้อมูลว่าง อยู่ห่างจาก crash สามชั้น

stand-in canvas ของ metafile ไม่ได้ใส่ค่ามิติทางกายภาพของมัน ค่านี้ถูกหารเข้าสู่การคำนวณเรขาคณิตของหน้า การคำนวณจึงให้ศูนย์ การหา bounding-box จึงหารด้วยศูนย์ exception handler เปล่า ๆ กลืนมันลงคอ โรงงานสร้างภาพคืนค่า null และ access violation จึงเกิดขึ้นในที่สุดที่ page tree เมื่อค่า null ถูกใช้ สามชั้นระหว่างสาเหตุกับอาการ โดยมี exception handler อยู่กลางทางลบหลักฐานทิ้ง

ห่วงโซ่ความล้มเหลวของ stand-in canvas metafile ที่ว่างเปล่าใน PDFlibPas ซึ่งไปสิ้นสุดเป็น access violation หลังการหารด้วยศูนย์ออกไปสามชั้น
มิติที่ว่างทำให้การคำนวณเรขาคณิตได้ศูนย์ handler เปล่าลบ exception ทิ้ง และ identifier ที่เป็น null ทำให้ page tree พัง

ยูนิตเดียวกันมีอีกสองกรณีของรูปแบบนี้ คลาส font มี body ของ Assign และ constructor ว่างเปล่า ซึ่งสำคัญกว่าที่ดู เพราะ property font ของ canvas เป็นแบบ read-only: การ assign เข้ามันเป็นหนทางเดียวที่จะส่ง font เข้าไป จึงทำให้ implementation ว่างเปล่าเปลี่ยนการเลือก font ให้ไร้ผลอย่างเงียบ ๆ และข้อความออกมาด้วยค่า default อะไรก็ได้ และค่า pixels-per-inch ที่เป็นศูนย์ทำให้ผู้เรียกทุกตัวที่กำหนดขนาด canvas จาก font metrics ได้ canvas ขนาดศูนย์คูณศูนย์ ซึ่งให้หน้าว่างและค่าคืนกลับแบบความสำเร็จ

// รูปร่างที่ต้องมองหาในยูนิต stand-in: เมธอดที่ไม่ raise และไม่ทำ
// อะไรเลย ทั้งสองตัวนี้ compile ผ่านและทั้งคู่ให้ "ความสำเร็จ" โดยไม่มีผลลัพธ์
procedure TMetafileCanvasStandIn.Create(...);
begin
  // ไม่มีการเรียก inherited, ไม่มีการกำหนดค่าเริ่มต้นของ field
end;

function TBitmapStandIn.LoadFromStream(Stream: TStream): Boolean;
begin
  Result := True;    // ทั้งที่ bitmap ยังว่างเปล่าอยู่
end;

โครงสร้าง wide ที่เก็บไว้แค่ตัวอักษรแรกตัวเดียว

กรณีนี้ไม่ใช่ปัญหาของ stand-in เลย แต่มันสมควรอยู่ในหมวดเดียวกัน เพราะอาการอยู่ห่างจากสาเหตุไกลพอ ๆ กัน โครงสร้าง enumeration ของ printer ถูกประกาศให้ string member ทั้งสิบสองตัวเป็น pointer ไปยังตัวอักษรไบต์เดียว ทั้งที่ฟังก์ชันที่เติมค่าให้มันเป็นตัวแปร wide-character ของ API การ enumerate

ขนาด pointer เท่ากัน โครงสร้าง layout จึงถูกต้องและไม่มีอะไรพัง สิ่งที่เกิดขึ้นแทนคือการอ่านสตริง UTF-16 เป็นสตริงไบต์เดียวจะหยุดที่ไบต์ศูนย์ตัวแรก ซึ่งสำหรับชื่อ printer ภาษาอังกฤษธรรมดาก็คือครึ่งบนของตัวอักษรที่สอง ชื่อ printer ทุกชื่อจึงกลับมาเป็นตัวอักษรเดียวพอดี ปลายน้ำคือการตรวจชื่อล้มเหลว การสร้าง printer ล้มเหลว และการพิมพ์ล้มเหลวกับ printer จริงทุกตัวบนเครื่อง โดยที่อาการเหล่านี้ไม่มีข้อใดชี้ไปที่การประกาศโครงสร้าง

ชื่อ printer UTF-16 ถูกตัดเหลือหนึ่งตัวอักษรหลังโครงสร้าง Win32 แบบ wide ถูกประกาศด้วย member PAnsiChar แทนที่จะเป็น PWideChar
member แบบไบต์เดียวอ่านชื่อ UTF-16 ได้ถึงแค่ไบต์ศูนย์ตัวแรก ชื่อ printer ทุกชื่อจึงกลับมาเหลือตัวอักษรเดียวพอดี
// ผิด: ขนาดถูก ชนิดสมาชิกผิด ไม่มี compile error ไม่มี crash
// ทุกสตริงถูกตัดเหลือหนึ่งตัวอักษร
type
  TPrinterInfo2Wrong = record
    pServerName: PAnsiChar;
    pPrinterName: PAnsiChar;
    // ... อีกสิบตัว
  end;

// ถูกต้อง: โครงสร้าง *W ต้องใช้ member แบบ wide ทั้งหมด
type
  TPrinterInfo2W = record
    pServerName: PWideChar;
    pPrinterName: PWideChar;
    // ... อีกสิบตัว
  end;

กฎที่ได้มาเป็นกฎเชิงกลไกที่ควรใช้ทันทีโดยไม่ต้องคิด: โครงสร้าง Win32 ใด ๆ ที่ชื่อลงท้ายด้วย W ให้ตรวจว่า string member ทุกตัวเป็นตัวแปร wide หรือไม่ ทีละ field การผสมโลก ANSI กับโลก wide ไม่ให้ทั้งคำเตือนจากคอมไพเลอร์และ crash เหลือเพียงการตัดทอนอย่างเงียบ ๆ และกฎเดียวกันใช้ย้อนกลับกับตัวแปร ANSI

exception handler เปล่า ๆ คือคู่ต่อสู้ที่แท้จริง

การไล่สอบทุกกรณีข้างบนถูกทำให้ช้าลงด้วยโครงสร้างเดียวกัน คือ handler ที่จับทุกอย่างแล้วแปลงเป็นค่าคืนกลับ false มันเป็นสิ่งที่เขียนได้อย่างสมเหตุผลรอบ ๆ image decoder เพราะภาพเสียไม่ควรลากงานเอกสารทั้งงานลงไปด้วย แต่มันก็เป็นเครื่องมือลบข้อมูลชิ้นเดียวที่คุณต้องการ

การตอบสนองเชิงปฏิบัติคือทำให้ handler ส่งเสียงดังชั่วคราว การ dump exception class ข้อความ และ backtrace จากข้างใน bare handler ภายใต้เงื่อนไข debug เปลี่ยนค่า null ที่อธิบายไม่ได้ให้เป็น exception ที่มีชื่อและตำแหน่ง ในสองจากสามกรณีข้างต้น ขั้นตอนเดียวนี้จบการสืบสวน เพราะ exception คือการหารด้วยศูนย์หรือ access violation ในเมธอดของ stand-in ที่ชื่อของมันบอกทุกอย่างแล้ว

เช็กลิสต์ก่อนรับ stand-in เป็นเส้นทางใช้งาน

สี่ข้อ เรียงตามลำดับที่ให้ผลตอบแทน ก่อนเรียกเข้าคลาสแทนที่ ให้อ่านเมธอดที่คุณกำลังจะใช้และยืนยันว่าแต่ละตัวมี body จริง body ว่าง ๆ ไม่ใช่รายละเอียดการ implement แต่คือฟีเจอร์ที่หายไป เลือก stand-in ที่ raise มากกว่า stand-in ที่คืนค่ากลาง ๆ และจับคู่ด้วย null check ที่จุดที่โรงงานสร้างภาพสามารถคืนค่าว่างได้อย่างชอบธรรมแล้ว ยืนยันฟีเจอร์ด้วยการส่องผลิตผล ไม่ใช่รหัสคืนกลับ เพราะ failure mode ทั้งหมดในที่นี้คือรหัสคืนกลับสะอาดทับผลิตผลว่างเปล่า การแยกย่อยระดับไบต์ว่าเอกสารมีอะไรอยู่จริงเป็นวิธีเห็นมันที่เร็วที่สุด และบทความ file size auditครอบคลุมเครื่องมือนั้น และเมื่อฟีเจอร์ใดไม่มี implementation แทนที่ที่ใช้งานได้จริง ให้เปลี่ยนเส้นทางตัวอย่างที่ได้รับผลไปยังเส้นทางที่ทำงานได้ พร้อมบอกเหตุผลไว้ในคอมเมนต์ แทนที่จะทิ้งการสาธิตที่ปล่อยผลว่างออกมาอย่างเงียบ ๆ

ข้อคิดที่กว้างกว่านี้ใช้ได้ไกลเกินไลบรารีเดียว codebase ใด ๆ ที่มี implementation ที่สองแบบมีเงื่อนไข ชั้น mock, headless mode, platform shim ล้วนเปิดรับรูปร่างที่สาม เหตุผลที่มันซ่อนเก่งคือทุก quality gate ที่ทีมพึ่งพาตามปกติ ทั้งรหัสคืนกลับ รหัส error, exception, exit status ล้วนเป็นช่องทางสถานะ และรูปร่างที่สามรักษาทุกช่องให้สะอาด มีเพียงผลลัพธ์เท่านั้นที่เผยมัน นั่นยังเป็นเหตุผลเบื้องหลังการเช็กผลิตผลมากกว่าสถานะเวลารับอินพุตที่ไม่น่าเชื่อถือ ตามที่อธิบายไว้ในบทความการ parse PDF ที่ไม่น่าเชื่อถือ และเบื้องหลังการเทียบผล render ระหว่าง engine แทนการเชื่อ engine เดียว ตามที่อธิบายไว้ในการ render หลาย engine

PDFlibPas เป็นไลบรารี PDF ภาษา Object Pascal เนทีฟสำหรับ Delphi, C++Builder และ Free Pascal และ configuration ที่ไม่มี VCL ของมันต่างหากที่ทำให้บิลด์แบบ headless และข้าม toolchain เป็นไปได้ ความครอบคลุม configuration ปัจจุบันระบุไว้บนหน้าผลิตภัณฑ์losLab PDF Developer Library