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

FillChar บนผลลัพธ์ของฟังก์ชันทำให้ String รั่วไหลใน Object Pascal

ฟังก์ชัน Delphi หรือ FPC ที่คืนค่าเป็น record ไม่ได้รับตัวแปร Result ใหม่ที่เป็นศูนย์ในทุกการเรียก ตัวแปร Result ที่ซ่อนอยู่นั้นเริ่มต้นเป็นศูนย์แค่ครั้งเดียวเป๊ะ และไม่มีอะไรตั้งค่าให้เป็นศูนย์ใหม่โดยอัตโนมัติระหว่างการเรียก ดังนั้นการเคลียร์มันตอนเข้าฟังก์ชันจึงเป็นหน้าที่ของฟังก์ชันเอง ทำการเคลียร์นั้นด้วย FillChar(Result, SizeOf(Result), 0) แล้วตั้งแต่การเรียกครั้งที่สองเป็นต้นไป รูทีนจะเขียนทับ reference ของ string หรือ dynamic-array ที่ยังใช้งานอยู่ แทนที่จะปล่อยมัน ทำให้ heap block ที่ reference นั้นชี้ไปกลายเป็นกำพร้า

สถานการณ์ที่สิ่งนี้กัดคือเรื่องธรรมดามาก กระบวนการ batch เปิดกอง PDF จากบุคคลที่สามและเดินผ่านทุก annotation บนทุกหน้า ดึงข้อความความคิดเห็นเข้า audit log ไม่มีอะไรใน loop นั้นที่ดูอันตรายเลย ทุกการเรียกเป็นแค่ฟังก์ชันธรรมดาที่คืน record ธรรมดา ไม่มี pointer ให้เห็น ไม่มีอะไรที่คล้ายการจัดการหน่วยความจำด้วยมือเลย การนับ reference ภายใน record เป็นกฎการบัญชีธรรมดาของ Object Pascal ไม่ใช่ความประหลาดเฉพาะของไลบรารีใดไลบรารีหนึ่ง และโค้ดเบส Delphi หรือ FPC ใดก็ตามที่ผสม FillChar กับ record type ที่ถือ string หรือ dynamic array อยู่ ก็เสี่ยงต่อข้อบกพร่องเดียวกันนี้

ทำไม FillChar บนผลลัพธ์ Record ถึงทำให้ String รั่วไหล

FillChar ทำให้ string รั่วไหลเพราะมันไม่รู้เลยว่ากำลังเขียนทับข้อมูลชนิดไหนอยู่ FillChar(X, Count, Value) ทำงานกับตัวแปรใดก็ได้ มันรับบล็อกไบต์ที่ไม่มี type ขนาด Count ไบต์ และประทับทุกตัวด้วย Value นั่นคือสัญญาทั้งหมด นี่คือสิ่งที่ทำให้ FillChar เร็วและใช้งานได้ทั่วไปพอดี เพราะมันไม่เคยตรวจสอบ type ของ X เลยและไม่เคยแยกสาขาตามความหมายของไบต์ข้างใต้ ฟิลด์ UnicodeString หรือ WideString ภายใน record ไม่ใช่ตัวอักษรเอง มันเป็น pointer ไปยัง heap block ที่พก reference count ไว้ก่อนข้อมูลตัวอักษร FillChar เห็นแค่ไบต์จำนวนหนึ่งที่บังเอิญถือค่า pointer และเขียนทับมันด้วยศูนย์เหมือนกับที่มันจะเขียนทับฟิลด์ Integer หรือ Double pointer หายไป reference count ที่มันควรลดก่อนไม่เคยถูกแตะเลย และ block ที่มันชี้ไปก็ยังคงถูกจัดสรรอยู่โดยไม่มีอะไรอ้างอิงถึงมันอีกต่อไป

คอมไพเลอร์ติดตาม String และ Dynamic Array ภายใน Record อย่างไร

Object Pascal เรียก type ว่ามีการจัดการเมื่อคอมไพเลอร์ต้องรันโค้ดเพิ่มเติมเพื่อรักษาความถูกต้องข้ามการกำหนดค่าและการออกจาก scope long string type อย่าง AnsiString, UnicodeString และ WideString เข้าข่ายนี้ เช่นเดียวกับ dynamic array, interface และ Variant พร้อมกับ record หรือ fixed array ใดก็ตามที่มีอย่างใดอย่างหนึ่งเหล่านั้นเป็นฟิลด์ สำหรับทุกฟิลด์ที่มีการจัดการ คอมไพเลอร์จะสร้างการบัญชีขึ้นเงียบๆ ที่มิฉะนั้นจะน่าเบื่อและผิดพลาดง่ายถ้าทำด้วยมือ คือเพิ่ม reference count ตอนกำหนดค่า, ลดมันเมื่อตัวแปรที่ถือมันถูกเขียนทับหรือออกจาก scope และ free block ข้างใต้เมื่อ count นั้นถึงศูนย์ กลไกนั้นคือเหตุผลที่โค้ด Pascal ทั่วไปไม่เคยจัดสรรหรือปล่อย string ด้วยมือเลย และเหตุผลที่การกำหนด dynamic array หนึ่งให้อีกตัวเป็นการดำเนินการที่ถูกและปลอดภัย แทนที่จะเป็น loop copy ด้วยมือ System.Default และ Finalize เป็นสองวิธีที่มีเอกสารประกอบในการเรียก logic การปล่อยเดียวกันนั้นตามต้องการ และเป็นสิ่งที่โค้ดเคลียร์ของ record ควรเรียก แทนที่จะเป็นการเติมหน่วยความจำแบบดิบ

type
  TLineItem = record
    Description: string;  // managed: reference-counted
    Quantity: Integer;    // unmanaged: plain ordinal
  end;

function GetLineItem(Index: Integer): TLineItem;
begin
  FillChar(Result, SizeOf(Result), 0);  // clears bytes, not the reference
  Result.Quantity := Source[Index].Qty;
  Result.Description := Source[Index].Text;
end;

var
  Item: TLineItem;
  I: Integer;
begin
  for I := 0 to High(Source) do
  begin
    Item := GetLineItem(I);  // second pass onward: leaks the prior Description
    Log.Add(Item.Description);
  end;
end;

ทำไมการรั่วไหลถึงเริ่มแค่ในการเรียกครั้งที่สอง

การเรียกครั้งแรกใน loop ไม่เป็นอันตรายเสมอ ซึ่งเป็นสิ่งที่ทำให้ข้อบกพร่องนี้พลาดได้ง่ายในการทดสอบพอดี ตัวแปรท้องถิ่นของ record type ที่มีการจัดการเริ่มต้นเป็นศูนย์ และไม่มีอะไรตั้งค่าให้เป็นศูนย์ใหม่โดยอัตโนมัติระหว่างรอบ loop หนึ่งกับรอบถัดไป ดังนั้นครั้งแรกที่ loop กำหนดค่าที่คืนกลับมาของฟังก์ชันเข้าตัวแปรนั้น ฟิลด์ Description หรือ ContentsText ของมันยังคงเป็น nil FillChar เขียนทับ nil ด้วยศูนย์ ซึ่งไม่เปลี่ยนแปลงอะไรเลยเท่าที่เกี่ยวกับ reference count และการเรียกก็คืนค่ากลับมาดูถูกต้องสมบูรณ์ การเรียกครั้งที่สองต่างออกไป ตัวแปรท้องถิ่นเดียวกันนั้นถืออะไรก็ตามที่การเรียกครั้งแรกเขียนเข้าไปแล้วอยู่แล้ว และ Result ของการเรียกใหม่ถูกเขียนตรงเข้าไปในที่เก็บเดียวกันนั้น แทนที่จะเป็นหน่วยความจำใหม่ที่ว่างเปล่า FillChar ที่ด้านบนของการเรียกครั้งที่สองนั้นเคลียร์ฟิลด์ที่ไม่ใช่ nil อีกต่อไปแล้ว และทุกอย่างที่อยู่ปลายทางของรูปแบบไบต์นั้นก็ผิดอย่างเงียบๆ ตั้งแต่นั้นเป็นต้นไป การทดสอบที่เรียกฟังก์ชันครั้งเดียวแล้วตรวจสอบผลลัพธ์จะไม่มีวันเห็นปัญหานี้เลย มีแค่ loop หรือ code path ใดก็ตามที่เรียกฟังก์ชันซ้ำๆ กับปลายทางเดียวกันเท่านั้นที่เปิดเผยมัน

การรั่วไหลจริง: Annotation, Bookmark และ Link Record

PDFiumPas เคยส่งข้อบกพร่องนี้ออกมาพอดีก่อนเวอร์ชัน 1.56.4 ในสามฟังก์ชันที่แต่ละตัวคืน record ที่ถือฟิลด์ที่มีการจัดการอย่างน้อยหนึ่งตัว ตัวอ่าน annotation ระดับหน้าคืน TPdfAnnotation ที่พก string ContentsText และ AuthorText ตัวอ่าน bookmark คืน TBookmark ที่พก string Title และตัวอ่าน link-annotation คืน TLinkAnnotation ที่พก string ActionPath และ dynamic array Points ทั้งสามเปิดด้วยรูปร่างเดียวกับที่แสดงด้านล่าง คือเคลียร์ Result ด้วย FillChar แบบดิบ แล้วเติมฟิลด์ทีละตัวจากข้อมูลหน้าข้างใต้ การเดินผ่านทุก annotation บนหน้าทีละตัว ซึ่งเป็นวิธีทั่วไปในการสร้างรายการ audit หรือแผง review เรียกตัวอ่าน annotation ใน loop และรั่วไหลข้อความของ annotation ก่อนหน้าในทุกรอบหลังจากรอบแรก PDF ที่สร้างขึ้นด้วย annotation ที่มีข้อความจำนวนผิดปกติมากสามารถทำให้หน่วยความจำของโปรเซสที่รันยาวนานเติบโตขึ้นตราบเท่าที่โปรเซสนั้นยังคงรันอยู่ การแก้ไขแตะแค่บรรทัดเดียวในแต่ละฟังก์ชัน การแทนที่ FillChar(Result, SizeOf(Result), 0) ด้วย Result := Default(TPdfAnnotation) ก็เพียงพอแล้ว เพราะการกำหนด Default ให้กับ record ที่มีการจัดการรันลำดับปล่อย-แล้ว-เคลียร์ปกติของคอมไพเลอร์ แทนที่จะเป็นการเติมหน่วยความจำแบบดิบ

function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
  Annotation: FPDF_ANNOTATION;
  ContentLength: LongWord;
begin
  Annotation := FPDFPage_GetAnnot(Page, Index);
  FillChar(Result, SizeOf(Result), 0);   // clears bytes, not a live reference
  Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
  ContentLength := FPDFAnnot_GetStringValue(Annotation,
    FPDFANNOT_TEXTTYPE_Contents, nil, 0);
  if ContentLength >= 4 then
  begin
    SetLength(Result.ContentsText, ContentLength div 2 - 1);
    FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
      Pointer(Result.ContentsText), ContentLength);
  end;
end;

อันตรายเดียวกันที่อยู่หลังพารามิเตอร์ var

ตัวอ่าน bookmark แสดงเวอร์ชันที่ละเอียดอ่อนกว่าของปัญหาเดียวกัน เพราะ record ที่ถูกเคลียร์ด้วย FillChar ไม่ใช่ Result ของฟังก์ชันเอง แต่เป็นพารามิเตอร์ var หนึ่งชั้นลึกลงไป SetBookmarkData รับเอาต์พุตของมันเป็น var Data: TBookmark และเคยเคลียร์ Data ที่ด้านบนของ body ของมันด้วย FillChar GetBookmark ฟังก์ชันสาธารณะที่คืน TBookmark จริงๆ เรียก SetBookmarkData และส่ง Result ของตัวเองผ่านตรงๆ เป็นอาร์กิวเมนต์ var นั้น พารามิเตอร์ var ถูกส่งผ่านโดย reference ดังนั้น Data ภายใน SetBookmarkData และ Result ภายใน GetBookmark จึงเป็นที่เก็บเดียวกันภายใต้สองชื่อ และความเสี่ยง aliasing ใดก็ตามที่ใช้กับ Result ของฟังก์ชันเองก็ใช้ตรงๆ กับรูทีน helper ใดก็ตามที่รับมันโดย reference ด้วย การ review แค่ฟังก์ชันที่ประกาศ return type เป็น record ตรงๆ พลาดรูปร่างนี้ไป การค้นหาต้องตามทุกพารามิเตอร์ var และ out ที่ Result ถูกส่งต่อเข้าไปด้วย

procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
  BufferSize: LongWord;
begin
  Data := Default(TBookmark);   // fixed: was FillChar(Data, SizeOf(Data), 0)
  Data.Handle := Bookmark;
  if Bookmark <> nil then
  begin
    BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
    if BufferSize >= 4 then
    begin
      SetLength(Data.Title, BufferSize div 2 - 1);
      FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
    end;
  end;
end;

function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
  CheckActive;
  SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;

เมื่อไหร่ที่ FillChar ยังคงเป็นตัวเลือกที่ถูกต้อง

FillChar ยังคงถูกต้องอยู่ และมักถูกกว่าเล็กน้อย สำหรับ record ที่สร้างขึ้นทั้งหมดจาก ordinal, floating-point field, fixed-size array ของสิ่งเหล่านั้น หรือ record ธรรมดาอื่นๆ ที่ทำจากสิ่งเดียวกัน เพราะไม่มีอะไรในนั้นให้คอมไพเลอร์ต้อง finalize เลย type สี่เหลี่ยมของ PDFiumPas เองเป็นกรณีนั้นพอดี TPdfRectangle ถือฟิลด์ Double สี่ตัวและไม่มีอะไรอื่นเลย และการเคลียร์มันด้วย FillChar ไม่ปล่อยอะไรเลยเพราะไม่มีอะไรที่นับ reference ให้ปล่อย การตรวจสอบที่แยกสองกรณีนี้ออกจากกันพูดได้ง่ายๆ ว่า มีฟิลด์ใดของ record ที่ระดับความลึกใดก็ตาม มี type เป็น string, AnsiString, WideString, dynamic array, interface หรือ Variant หรือไม่ record หนึ่งอาจดูเป็นตัวเลขล้วนๆ ในระดับบนสุด แต่ยังคงล้มเหลวในการทดสอบนั้นได้ ถ้าฟิลด์หนึ่งของมันเองเป็น record ที่ฝัง string ไว้ลึกลงไปไม่กี่ชั้น ดังนั้นการตรวจสอบต้องตาม record ที่ซ้อนกันไปตลอดทาง แทนที่จะหยุดที่รายการฟิลด์ชั้นนอกสุด การตรวจสอบโค้ดเบสที่มีอยู่แล้วหารูปแบบนี้เป็นกลไก ไม่ใช่การทำแบบละเอียดถี่ถ้วน ค้นหาการเรียก FillChar ทุกตัวที่เป้าหมายเป็นตัวแปร record แล้วตรวจสอบรายการฟิลด์ของ record นั้นเทียบกับรายการ managed-type ข้างต้น การตรวจสอบ v1.56.4 ของ PDFiumPas เองรันการค้นหานั้นพอดีข้ามทั้งไลบรารีและพบความเสี่ยงนี้ใน unit เดียว ทุกจุดเรียก FillChar อื่นเคลียร์ record ตัวเลขธรรมดาอยู่แล้ว ซึ่ง FillChar เป็นเครื่องมือที่ถูกต้อง และยังคงเป็นอยู่

พฤติกรรมคอมไพเลอร์เดียวกันที่ทำให้ Result ที่ใช้ซ้ำเป็นอันตรายตรงนี้ ยังขับเคลื่อนตระกูลความไม่ตรงกันระหว่าง Delphi กับ FPC ที่เกี่ยวข้องกันในที่อื่นในโค้ดเบสนี้ด้วย บทความคู่กันเรื่องข้อผิดพลาดข้ามคอมไพเลอร์ ครอบคลุมกรณีที่ FPC กับ Delphi ไม่เห็นตรงกันว่า record-result ชั่วคราวถูก finalize เมื่อไหร่กันแน่ภายใน expression เดียว ซึ่งเป็นอาการที่ต่างกันของข้อเท็จจริงพื้นฐานเดียวกันที่ว่า record Result ของฟังก์ชันไม่ใช่ที่เก็บส่วนตัวใหม่เอี่ยมเสมอไปตามที่มันดู loop annotation ที่ใช้เป็นตัวอย่างวิ่งตลอดบทความนี้ก็ไม่ใช่แค่สมมติฐานด้วย มันคือการเดินทีละหน้าเดียวกับที่คุณจะเขียนขณะสร้างแผง review annotation ซึ่งเป็นรูปร่างโค้ดพอดีที่เปลี่ยน FillChar บรรทัดเดียวให้กลายเป็นหน่วยความจำรั่วไหลช้าๆ ตั้งแต่แรก

ไม่มีอะไรในนี้ที่ต้องการเปลี่ยนไลบรารีหรือไล่ตามบั๊กในโค้ดที่ compile แล้วของคนอื่น มันเป็นคุณสมบัติของภาษา Object Pascal เอง ที่นักพัฒนา Delphi และ FPC ทุกคนทำงานด้วยทุกวัน และทางแก้เป็นการเรียกฟังก์ชันเดียวเมื่อคุณรู้ว่าต้องมองหาอะไร API annotation, bookmark และ link-annotation ที่อธิบายในบทความนี้มาพร้อมกับPDFium Componentสำหรับ Delphi, C++Builder และ Lazarus/FPC ควบคู่ไปกับพื้นผิวการอ่าน PDF, การ render และ annotation ที่เหลือซึ่งครอบคลุมอยู่ในที่อื่นของบล็อกนี้