Artikel Teknis

Melepas Object Graph PDF Tepat Sekali di Delphi: HotPDF

HotPDF Delphi Component melepas setiap objek PDF milik dokumen saat dokumen itu ditutup atau dimuat ulang: THotPDF.CloseIndirectObjects menelusuri registry objek, mengumpulkan setiap owning edge ke sebuah pointer set, melepaskan semua edge itu, dan baru setelah itu meng-free setiap node unik beserta setiap payload stream tepat sekali. Urutan tiga fase itulah yang membuat child yang dipakai bersama, siklus kepemilikan, registrasi ganda, dan alias wrapper/body semuanya bisa dibongkar tanpa double free dan tanpa meninggalkan apa pun. Sebelum v2.752.4 rutin yang sama melakukan sesuatu yang jauh lebih sederhana dan jauh lebih buruk: ia melepas sumber file stream yang malas, memanggil Clear pada list IndirectObjects, meng-free container list-nya, lalu menyerahkan setiap objek PDF yang sebenarnya ke process exit untuk direklamasi. Komentar di kode itu juga jujur soal itu. Meng-free objeknya satu per satu menyebabkan access violation, jadi "pendekatan yang aman" adalah tidak meng-free-nya sama sekali. Tulisan ini soal kenapa pendekatan satu-per-satu itu benar-benar crash, dan bagaimana bentuk teardown yang berhasil di bahasa dengan manajemen memori manual

Kenapa Anda tidak bisa sekadar Free setiap objek yang terdaftar?

Karena destructor kelas-kelas objeknya tidak sepakat soal siapa memiliki apa, dan registry-nya berisi entri di beberapa tingkat dari rantai kepemilikan yang sama. Menelusuri list-nya lalu memanggil Free pada setiap entri karena itu meng-free sebagian memori dua kali dan sebagian lagi tidak pernah, tergantung kelas mana yang kebetulan bersebelahan

Tiga asimetri di HPDFObjs.pas dan HPDFDoc.pas menciptakan masalahnya. THPDFDictionaryObject.Destroy menelusuri Items-nya dan meng-free sebuah nilai hanya ketika IsIndirect bernilai False, dengan asumsi child tak langsung milik registry dan akan di-free di sana. THPDFArrayObject.Destroy tidak membuat pembedaan semacam itu dan meng-free setiap item yang dipegangnya. Dan THPDFIndirectObject.Destroy, wrapper yang membawa nomor objek, meng-free body InternalObject-nya. Sekarang bayangkan registry yang memegang dictionary tak langsung, sebuah array yang mencantumkan dictionary yang sama di salah satu slotnya, dan sebuah wrapper yang body-nya juga terdaftar sebagai root terpisah, dan itulah persis yang dihasilkan parser pada file nyata. Free array-nya dulu dan dictionary-nya sudah hilang sebelum registry mencapainya. Free wrapper beserta body-nya, dalam urutan mana pun, dan panggilan kedua menjalankan destructor pada pointer yang menggantung. Free dictionary-nya sendirian dan child tak langsung mana pun yang dilewatkannya tetap teralokasi selamanya. Tidak ada urutan registry yang memperbaiki ini, karena registry-nya adalah list datar sedangkan relasi kepemilikannya adalah graph, dan menalar soal graph-nya adalah satu-satunya jalan keluar

Kenapa meng-free setiap entri registry HotPDF crash: THPDFDictionaryObject.Destroy melewati child tak langsung sementara THPDFArrayObject.Destroy meng-free semua yang dipegangnya dan THPDFIndirectObject.Destroy meng-free body InternalObject-nya, jadi dengan wrapper, array, dan dictionary bersama di satu list IndirectObjects yang datar sebagian memori mati dua kali dan sebagian tidak pernah
Para destructor-nya tidak sepakat soal siapa memiliki apa, dan registry-nya memegang entri di beberapa tingkat dari rantai kepemilikan yang sama, jadi tidak ada urutan list datar yang bisa mengubah Free per objek yang naif jadi teardown yang benar

Apa yang dihitung sebagai owning edge di object graph PDF?

Owning edge adalah pointer yang targetnya menjadi tanggung jawab sumbernya untuk dihancurkan; referensi adalah apa pun selain itu, dan teardown-nya harus mengikuti jenis yang pertama dan mengabaikan yang kedua. Di HotPDF itu memberi tepat empat jenis edge: Items dari sebuah THPDFDictionaryObject, Items dari sebuah THPDFArrayObject, InternalObject di balik sebuah THPDFIndirectObject, dan kedua belahan sebuah THPDFStreamObject, yaitu Dictionary dan payload Stream-nya. Jenis referensinya sama pentingnya, karena mengikuti salah satunya mengubah penelusuran graph jadi loop tak berujung atau use-after-free. Sebuah THPDFLink memegang nomor objek dan generation, yang merupakan cara ISO 32000-1 §7.3.10 mendefinisikan referensi tak langsung: sebuah nama untuk objek yang hidup di tempat lain, bukan objeknya sendiri. Meresolusi nomor itu lewat registry menghasilkan node yang sudah dimiliki edge lain, jadi CloseIndirectObjects sama sekali tidak melakukan dereference terhadap link. Back-pointer FParent yang disimpan dictionary dan array adalah cerita yang sama dari arah sebaliknya; parent-nya sudah memiliki child-nya, jadi mengikuti pointer ke atas hanya akan mengunjungi ulang node yang sudah dilewati penelusuran. Keduanya dibiarkan, dan komentar di sumbernya menyatakan itu dalam satu baris: link dan parent pointer adalah referensi, bukan owning edge

Owning edge versus referensi di object graph HotPDF: Items DictionaryObject, Items ArrayObject, InternalObject IndirectObject, dan kedua belahan StreamObject diikuti lalu dilepaskan, sementara nomor objek THPDFLink dan back-pointer FParent adalah nama untuk objek yang hidup di tempat lain, jadi CloseIndirectObjects tidak pernah melakukan dereference terhadapnya
Owning edge adalah pointer yang targetnya wajib dihancurkan oleh sumbernya; mengikuti referensi justru akan mengubah penelusuran breadth-first jadi loop tak berujung atau use-after-free, jadi link dan parent pointer dibiarkan

Bagaimana teardown tiga fase itu bekerja?

Fase satu adalah pengumpulan breadth-first. Rutinnya mengisi worklist dengan setiap entri IndirectObjects, lalu untuk setiap node ia menambahkan target dari owning edge node itu, melewati apa pun yang sudah terlihat. Seen-set-nya adalah array open-addressing berisi pointer mentah yang di-hash dengan HPDFFastCacheHashInt64 atas nilai pointernya, dengan linear probing dan GrowSeen yang menggandakan ukuran ketika terisi separuh. Tidak ada apa pun di struktur itu yang mengalokasikan per node, dan itu penting ketika sebuah dokumen membawa beberapa ratus ribu objek. Payload stream masuk ke list Streams terpisah karena mereka turunan TStream dan bukan node THPDFObject, dan di-free di pass-nya sendiri

Teardown tiga fase CloseIndirectObjects di HotPDF: pengumpulan breadth-first mengisi worklist dari IndirectObjects dan hanya mengikuti owning edge lewat seen-set open-addressed yang di-hash dengan HPDFFastCacheHashInt64, fase dua melepaskan setiap edge dengan MarkAsFreed dan pengisian nil, dan fase tiga meng-free setiap node serta payload stream tepat sekali
Memotong edge-nya sebelum destructor mana pun berjalan adalah yang membuat destructor yang ada aman dipakai ulang: masing-masingnya lalu tidak menemukan apa pun untuk direkursi, jadi child yang dipakai bersama, siklus, dan alias wrapper-body semuanya bongkar tanpa double free
procedure Collect(Value: TObject; Payload: boolean);
var
  Slot: Integer;
begin
  if Value = nil then Exit;
  if (SeenCount + 1) * 2 >= Length(Seen) then GrowSeen;
  Slot := PointerSlot(Pointer(Value), Length(Seen));
  while Seen[Slot] <> nil do
  begin
    if Seen[Slot] = Pointer(Value) then Exit;   // sudah terkumpul
    Slot := (Slot + 1) and (Length(Seen) - 1);
  end;
  Seen[Slot] := Pointer(Value);
  Inc(SeenCount);
  if Payload then Streams.Add(Value) else Nodes.Add(Value);
end;

// Fase satu: isi dari registry, lalu ikuti hanya owning edge
for I := 0 to IndirectObjects.Count - 1 do
  Collect(TObject(IndirectObjects[I]), False);
I := 0;
while I < Nodes.Count do
begin
  Obj := THPDFObject(Nodes[I]);
  if Obj is THPDFIndirectObject then
    Collect(THPDFIndirectObject(Obj).InternalObject, False)
  else if Obj is THPDFStreamObject then
  begin
    Collect(THPDFStreamObject(Obj).Dictionary, False);
    Collect(THPDFStreamObject(Obj).Stream, True);
  end
  else if Obj is THPDFDictionaryObject then
    for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
      Collect(PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value, False)
  else if Obj is THPDFArrayObject then
    for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
      Collect(TObject(THPDFArrayObject(Obj).Items[J]), False);
  Inc(I);
end;

Fase dua adalah bagian yang membuat destructor-nya aman dijalankan: setiap owning edge disetel nil sebelum destructor mana pun berjalan. Sebuah wrapper mendapat MarkAsFreed, yang membersihkan FInternalObject dan menyetel flag yang diperiksa lebih dulu oleh destructor-nya. Stream object punya Dictionary dan Stream yang diisi nil. Setiap item dictionary punya Item^.Value yang dibersihkan dan setiap slot array ditimpa nil. Setelah pass ini graph-nya tidak punya edge tersisa, jadi ketika fase tiga memanggil Free pada setiap node di Nodes lalu setiap payload di Streams, masing-masing destructor tidak menemukan apa pun untuk direkursi dan hanya menghancurkan dirinya sendiri

// Fase dua: lepaskan setiap owning edge sebelum meng-free apa pun
for I := 0 to Nodes.Count - 1 do
begin
  Obj := THPDFObject(Nodes[I]);
  if Obj is THPDFIndirectObject then
    THPDFIndirectObject(Obj).MarkAsFreed
  else if Obj is THPDFStreamObject then
  begin
    THPDFStreamObject(Obj).Dictionary := nil;
    THPDFStreamObject(Obj).Stream := nil;
  end
  else if Obj is THPDFDictionaryObject then
    for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
      PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value := nil
  else if Obj is THPDFArrayObject then
    for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
      THPDFArrayObject(Obj).Items[J] := nil;
end;

// Fase tiga: setiap node dan payload unik di-free tepat sekali
IndirectObjects.Clear;
for I := 0 to Nodes.Count - 1 do TObject(Nodes[I]).Free;
for I := 0 to Streams.Count - 1 do TObject(Streams[I]).Free;
FreeAndNil(IndirectObjects);

Lihat apa yang didapat dari pemisahan itu. Dictionary yang dipakai bersama dua stream object dikumpulkan sekali, dilepaskan dari keduanya, dan di-free sekali. Siklus di mana sebuah array mencantumkan dictionary parent-nya sendiri berhenti karena seen-set-nya menolak kunjungan kedua. Wrapper dan body-nya yang sama-sama terdaftar sebagai root adalah dua pointer berbeda di set-nya, jadi keduanya di-free, dan destructor wrapper-nya tidak lagi mencoba meng-free body-nya karena MarkAsFreed sudah mengambil edge itu. Satu TMemoryStream yang ditugaskan sebagai payload dua stream object berada di Streams tepat sekali. Tak satu pun dari kasus itu butuh penanganan khusus, dan itulah tanda model-nya benar

Bagaimana membedakan kebocoran dari retensi allocator?

Dengan memeriksa apakah jumlah alokasi hidup milik memory manager bergerak mengikuti beban kerjanya, bukan hanya jejak yang direservasi. Memory manager Delphi menyimpan blok besar yang sudah di-free untuk dipakai ulang, jadi proses yang tetap di 400 MiB setelah menutup dokumen belum tentu bocor; proses yang jumlah blok hidupnya naik satu per halaman per run sudah pasti. Probe yang memicu perbaikan ini sengaja kecil: satu writer THotPDF yang menghasilkan satu halaman, lalu tiga reader yang memuatnya. Setelah keempatnya di-free, laporan heap menunjukkan tepat empat alokasi hidup berukuran 512 KiB, satu per instance, yaitu payload content stream yang dimiliki masing-masingnya dan tidak pernah dilepas. Memperbesar skalanya membuat pola yang sama jadi tak bisa disalahartikan. Menjalankan pipeline render paralel dua kali menggeser angka blok besar yang teralokasi dari 384 MiB ke 640 MiB, kenaikan yang sebanding dengan jumlah halaman dan tidak bisa dijelaskan retensi allocator. Setelah penulisan ulang, diagnostik satu halaman melaporkan nol byte besar teralokasi dan nol yang direservasi begitu instance-nya hilang. Kalau Anda sedang memburu pertumbuhan sejenis di proses Anda sendiri, object dependency graph dengan retained bytes memberi tahu objek mana yang memegang memorinya selama dokumennya terbuka; tulisan ini soal release behavior mereka ketika dokumennya ditutup

Ambang memori membuat test regresi jadi rapuh, jadi test yang dikirim menghitung panggilan destructor saja. Sebuah fixture membangun graph patologis itu dengan tangan: dictionary bersama di bawah dua stream, array yang memuat dictionary bersama itu sekaligus root-nya sendiri, satu payload yang ditugaskan ke kedua stream, root yang terdaftar dua kali, dan wrapper yang body-nya terdaftar terpisah, lalu meng-free dokumennya dan memastikan satu penghancuran per objek unik: satu payload, dua stream, dua dictionary, satu array, satu wrapper, satu number. Di bawah kode lama, ketiga test masa hidup itu melaporkan nol penghancuran, dan itu pernyataan paling langsung soal apa arti "serahkan ke process exit"

Apa yang harus terjadi sebelum graph-nya dibongkar?

Pekerjaan latar apa pun yang meminjam objek dari graph harus berhenti lebih dulu, dan cache apa pun yang memegang display list atau bitmap hasil kompilasi dari objek-objek itu harus dibuang, kalau tidak worker thread atau referensi yang di-cache akan membaca memori yang sudah di-free. CloseIndirectObjects karena itu dibuka dengan CancelLoadedPagePrefetch, lalu membatalkan validitas cache halaman terender sebelum menyentuh registry-nya. Jalur muat ulang di LoadFromFile dan LoadFromStream serta destructor komponennya sama-sama lewat sana, jadi urutan yang sama berlaku entah Anda mengganti dokumen atau membuang instance-nya; aturan untuk memakai ulang satu THotPDF lintas dokumen bersandar pada jaminan itu. Dua detail di pembuka itu baru muncul saat menjalankan test-nya. Pertama, destructor-nya sudah membuang frequency sketch di balik cache render dan display list pada saat ia menutup graph-nya, jadi pembatalan validitasnya dijaga dengan syarat field itu tidak nil alih-alih dipanggil tanpa syarat. Kedua, InvalidateRenderedPageCache adalah rutin yang menembakkan OnLoadedDocumentModified dengan indeks halaman -1, dan pemanggil yang memuat ulang file seharusnya tidak menerima notifikasi penyuntingan untuk teardown internal dokumen lama. Handler-nya disimpan, disetel nil selama panggilan, dan dipulihkan di blok finally, dan regresi muat ulangnya memastikan jumlah notifikasinya nol setelah LoadFromStream yang kedua. Perbaikan memori yang diam-diam mengubah kontrak event adalah regresi dengan PR yang lebih bagus, jadi ia mendapat assertion-nya sendiri. Kalau Anda menjalankan pipeline render paralel terhadap sebuah dokumen lalu memuat ulangnya, langkah pembatalan itulah yang mencegah worker pool-nya berlomba dengan teardown-nya

Memakai ulang pola ini di kode Delphi Anda sendiri

Tekniknya tidak khusus PDF. Object model Delphi apa pun yang destructor-nya memiliki child secara tidak konsisten, di mana child yang sama bisa dicapai dari beberapa parent, atau di mana back-pointer dan forward pointer hidup berdampingan, akan crash atau bocor di bawah Free per objek yang naif. Perbaikannya selalu berbentuk sama: putuskan field pointer mana yang owning dan mana yang referensi, kumpulkan penutupan owning edge-nya lewat pointer set yang tahan kunjungan ulang, potong setiap edge, lalu hancurkan list datarnya. Langkah memotong itulah yang biasanya dilewati orang, dan justru itulah yang membuat destructor yang ada aman dipakai ulang alih-alih memaksa penulisan ulang setiap kelas di modelnya. Batasnya tetap layak dinyatakan terang-terangan. Pointer set-nya memakai alamat objek sebagai identitas, jadi objek yang sudah di-free dan alamatnya dipakai ulang oleh alokasi baru akan tak bisa dibedakan; urutannya menjamin tidak ada destructor yang berjalan selama pengumpulan, dan itulah yang menutup kemungkinan itu. Penelusurannya hanya melihat empat jenis edge yang dikenalnya, jadi kelas baru yang memiliki child lewat field yang tidak diperiksa penelusurannya akan membocorkan child itu sampai penelusurannya diajari soal itu. Dan karena link-nya diresolusi lewat registry alih-alih diikuti, objek yang hanya dirujuk sebuah link dan tidak pernah terdaftar tidak terjangkau oleh teardown ini sama sekali; di HotPDF parser-nya menjamin registrasinya, tapi graph yang dibangun tangan harus menghormati aturan yang sama

Semua ini ada di dalam komponennya, jadi efek yang terlihat bagi aplikasi sekadar bahwa menutup atau memuat ulang dokumen mengembalikan memorinya, tanpa perubahan API. HotPDF adalah library PDF VCL native untuk Delphi dan C++Builder dengan source lengkap; referensi API dan build trial-nya ada di halaman komponen PDF HotPDF untuk Delphi