Artikel Teknis

Reentrancy OnExit dalam Editor Form-Field In-Place Delphi

Kontrol TPDFlibViewer milik PDFlibPas meng-commit sebuah editor field form in-place lewat event OnExit milik editor itu sendiri, dan pilihan desain itu menyembunyikan sebuah jebakan VCL Delphi klasik: menyembunyikan, mengganti parent, atau menghancurkan sebuah kontrol yang sedang terfokus dari dalam handler OnExit-nya sendiri bisa memicu OnExit kedua kalinya sebelum pemanggilan pertama kembali, mengirim logika commit itu kembali ke dalam dirinya sendiri

Kegagalan yang dihasilkan ini menolak reproduksi yang bersih. Seorang pengguna Tab dengan cepat melalui sebuah rangkaian field teks pada sebuah formulir aplikasi hasil scan, dan sesekali viewer itu memunculkan sebuah access violation, atau lebih buruk, terus berjalan sambil diam-diam menulis nilai yang salah ke dalam sebuah field dua tab ke belakang. Reproduksi sesuai permintaan dan bug itu terlihat jelas dengan kacamata belakang; kejar dari satu laporan crash pelanggan dan itu terlihat seperti sebuah hantu, karena apakah OnExit kedua benar-benar terpicu bergantung pada timing window-handle dan fokus yang bergeser dengan tipe field, kecepatan mengetik, dan apa pun lagi yang sedang dilakukan message queue pada saat itu

Bagaimana TPDFlibViewer menempatkan sebuah editor sungguhan di atas sebuah halaman yang dirender

TPDFlibViewer merender setiap halaman PDF menjadi sebuah bitmap dan tidak mengubah field form menjadi kontrol VCL hidup secara default, sehingga BeginEditFormField adalah metode yang menjembatani kedua dunia itu: dipanggil dengan sebuah indeks field, ia mencari rectangle field itu dan mengonversinya ke koordinat klien, lalu menjatuhkan sebuah TEdit atau TMemo sungguhan di atas rectangle itu untuk sebuah field teks, atau sebuah TComboBox dalam gaya csDropDownList untuk sebuah field pilihan, lengkap dengan nilai saat ini field itu sudah dimuat. ISO 32000-2 §12.7 mendefinisikan apa itu sebuah field form teks atau pilihan di dalam sebuah PDF, tetapi tidak ada apa pun dalam spesifikasi itu yang mengatakan bagaimana sebuah aplikasi Windows seharusnya membiarkan seseorang mengetik ke dalamnya, dan celah itu persis apa yang ingin diisi keberadaan BeginEditFormField. Baik OnKeyDown maupun OnExit dikabel ke dua metode viewer yang sama, InplaceEditorKeyDown dan InplaceEditorExit, pada setiap editor yang dibuat TPDFlibViewer, sebuah pemasangan yang sudah dirilis tak berubah sejak pengisian form interaktif pertama kali mendarat di v3.220.0, dan OnExit adalah tempat masalah dimulai

Mengapa menyembunyikan editor memicu OnExit kedua kalinya?

TWinControl dalam VCL memperlakukan sebuah perubahan pada Visible atau Parent milik sebuah kontrol terfokus sebagai sebuah alasan untuk memindahkan fokus darinya segera, dan memindahkan fokus dari sebuah kontrol persis apa yang memicu event OnExit kontrol itu, secara sinkron, sebelum assignment properti yang memicunya bahkan kembali. CommitInplaceEditor, metode yang digunakan PDFlibPas untuk menutup editor in-place dan menulis nilainya kembali ke field form, perlu melakukan persis dua hal itu saat keluar: mengatur Editor.Visible ke False dan mengatur Editor.Parent ke nil sehingga kontrol itu berhenti menggambar di atas halaman dan berhenti menerima input. Lakukan salah satu dari itu selagi editor itu masih memiliki fokus, yang hampir selalu terjadi karena pengguna baru saja meninggalkannya, dan OnExit terpicu lagi di tengah pemanggilan yang seharusnya menjadi hal terakhir yang pernah dipicu OnExit editor itu

Apa yang salah ketika CommitInplaceEditor ter-reentry ke dirinya sendiri?

Sebuah metode commit naif membayar ini dengan salah satu dari dua cara. Baik ia menulis nilai field itu dua kali, sekali dari pemanggilan asli dan sekali dari pemanggilan reentrant yang menyelinap masuk sebelum yang pertama selesai menyentuh state-nya sendiri, atau ia mencoba membebaskan kontrol editor selagi sebuah frame lebih jauh ke bawah call stack masih berada di dalam event handler kontrol yang sama itu sendiri, yang merupakan wilayah tak terdefinisi dalam VCL dan muncul sebagai sebuah access violation yang bisa menunjuk ke hampir baris mana pun, tidak selalu yang sebenarnya menyebabkannya. Tak satu pun kegagalan itu membutuhkan sebuah form besar untuk memicunya; sebuah dokumen dua-field sudah cukup, asalkan pengguna meninggalkan field kedua cukup cepat agar OS masih membongkar pesan fokus dari yang pertama

// Naive version: reads fine in review, fails only under real typing speed
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // still running inside FEditor's own OnExit
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // focused control reparented here: OnExit
                               // fires again, re-entering this same method
  FEditor.Free;                // freed while a caller further down the
  FEditor := nil;              // stack is still inside its OnExit handler
end;

Nolkan referensi sebelum Anda menyentuh kontrol

Perbaikan yang dirilis PDFlibPas adalah sebuah pengurutan ulang tunggal: tangkap editor itu dalam sebuah variabel lokal, kosongkan field yang menunjuk padanya, dan hanya kemudian mulai mengubah properti kontrol tersebut. CommitInplaceEditor membaca FInplaceEditor ke dalam sebuah variabel lokal Editor, mengatur FInplaceEditor ke nil segera, dan hanya setelah itu menetapkan Editor.Visible dan Editor.Parent. Sebuah pemanggilan reentrant yang terpicu oleh salah satu dari kedua assignment itu membaca FInplaceEditor itu sendiri, menemukannya sudah nil, dan keluar pada baris pertamanya, sebelum ia bisa menyentuh Editor atau menulis nilai field itu untuk kedua kalinya

procedure TPDFlibViewer.CommitInplaceEditor(Save: Boolean);
var
  Editor: TWinControl;
begin
  Editor := FInplaceEditor;
  if not Assigned(Editor) then
    Exit;                      // a reentrant call lands here and stops
  FInplaceEditor := nil;       // detach before the control is touched at all
  if Save then
    SaveEditorValue(Editor);   // safe: FInplaceEditor is already nil
  Editor.Visible := False;
  Editor.Parent := nil;        // may fire OnExit again; the guard above
                                // turns that reentrant call into a no-op
  ReapDeadEditor;               // free whatever was parked last cycle
  FDeadEditor := Editor;        // park this one instead of freeing it here
end;

SaveEditorValue dalam listing itu mewakili cabang sesungguhnya, yang memeriksa apakah Editor adalah sebuah TComboBox, sebuah TMemo, atau sebuah TEdit dan membaca nilainya sesuai, karena PDFlibPas membuat sebuah kontrol berbeda bergantung pada apakah field itu sebuah field teks atau sebuah field pilihan. Penjaga itu tidak peduli cabang mana yang berjalan, hanya bahwa FInplaceEditor nil sebelum apa pun yang mampu memicu OnExit dieksekusi, yang merupakan satu batasan urutan yang membuat sisa metode itu aman ditulis dengan gaya apa pun yang sebaliknya alami

Jangan pernah membebaskan sebuah kontrol dari dalam event-nya sendiri

TPDFlibViewer.CommitInplaceEditor tidak pernah memanggil Editor.Free secara langsung, dan itu disengaja: membebaskan sebuah kontrol tidak aman selagi sebuah stack frame milik kontrol yang sama itu sendiri mungkin masih membongkar di atas pemanggilan yang membebaskannya, reentrant OnExit atau bukan. PDFlibPas sebagai gantinya menyerahkan editor yang terlepas ke sebuah tempat parkir slot-tunggal, FDeadEditor, membebaskan apa pun yang duduk di sana dari siklus edit sebelumnya lewat sebuah helper kecil, ReapDeadEditor, dipanggil di awal BeginEditFormField berikutnya dan sekali lagi dari CloseDocument; setiap editor yang dibuat viewer itu juga dimiliki viewer itu sendiri, TEdit.Create(Self) alih-alih TEdit.Create(nil), sehingga bahkan sebuah kontrol yang masih terparkir di FDeadEditor ketika viewer itu dihancurkan disapu oleh kepemilikan komponen VCL biasa alih-alih bocor

procedure TPDFlibViewer.ReapDeadEditor;
begin
  if Assigned(FDeadEditor) then
  begin
    FDeadEditor.Free;          // safe now: this control's own OnExit
    FDeadEditor := nil;        // finished at least one edit cycle ago
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // flush whatever editor is still open
  // ... field lookup and rectangle conversion omitted ...
  ReapDeadEditor;              // now safe to free last cycle's parked editor
  Edit := TEdit.Create(Self);
  Edit.Parent := Self;
  Edit.OnExit := InplaceEditorExit;
  FInplaceEditor := Edit;
  FInplaceEditor.SetFocus;
  Result := 1;
end;

Mengapa ini paling terlihat parah selama navigasi Tab cepat?

FocusNextFormField, metode yang ditambahkan PDFlibPas di v3.226.0 untuk menggerakkan navigasi Tab dan Shift+Tab di seluruh sebuah form, memanggil BeginEditFormField untuk field yang memenuhi syarat berikutnya pada setiap lompatan tunggal, dan BeginEditFormField dibuka dengan memanggil CommitInplaceEditor(True) untuk menyiram apa pun editor yang ditinggalkan terbuka field sebelumnya. Itu berarti setiap penekanan Tab yang dibuat pengguna selagi mengisi sebuah form multi-field menjalankan persis urutan lepaskan-lalu-sentuh yang dijelaskan di atas sekali, yang persis merupakan jalur kode paling mungkin masih memiliki sebuah kontrol yang benar-benar terfokus pada saat Visible dan Parent berubah, karena Tab adalah satu interaksi yang hampir dijamin meninggalkan editor keluar memegang fokus persis sampai yang baru memintanya

Tak satu pun dari ini membuat bug ini andal untuk didemonstrasikan, dan itu layak dikatakan secara terus terang alih-alih diglosskan. Apakah sebuah assignment Visible atau Parent tertentu benar-benar memaksa sebuah OnExit sinkron bergantung pada state fokus dan window-handle yang diubah sebuah debugger hanya dengan terpasang, yang bisa diganggu sebuah repaint atau timer tak-berkaitan, dan yang berperilaku berbeda bergantung pada mana dari TEdit, TMemo, atau TComboBox yang kebetulan menjadi kontrol yang berperan. Sebuah penjaga yang hanya kadang-kadang dijalankan adalah alasan mengapa jenis cacat ini bertahan dari code review dan pengujian manual sekaligus, dan itu juga alasan mengapa perbaikannya harus benar berdasarkan konstruksi, menolkan referensi sebelum apa pun lagi terjadi, alih-alih benar berdasarkan perilaku apa pun yang kebetulan diamati segelintir langkah test manual

Bentuk umum perbaikan ini berjalan jauh melampaui satu kontrol viewer. Permukaan editing kustom apa pun yang dibangun dengan melapiskan sebuah kontrol VCL hidup di atas konten yang dirender, bukan hanya sebuah field form PDF, mewarisi bahaya yang sama begitu logika tutup-dan-commit-nya bisa dipicu baik oleh sebuah aksi pengguna eksplisit maupun sebuah perubahan fokus implisit, dan jawaban dua-bagian yang sama berlaku: kosongkan referensi yang mengidentifikasi kontrol aktif sebelum melakukan apa pun yang mungkin memicu event exit-nya sendiri, dan jangan pernah memanggil Free dari sebuah jalur kode yang mungkin masih berjalan di bawah dispatch event kontrol itu sendiri. Permukaan pengisian-form dan rendering yang lebih luas milik TPDFlibViewer, termasuk bagaimana ia memutuskan tipe kontrol mana untuk ditampilkan untuk field mana, dibahas di ikhtisar membangun sebuah kontrol PDF viewer interaktif di Delphi VCL dengan PDFlibPas, dan cache bitmap halaman yang harus dibatalkan SetFormFieldValueAndRefresh pada setiap edit yang di-commit dibahas terpisah di tulisan tentang disk page cache DPI per-monitor milik viewer

Editing field form in-place, navigasi field digerakkan-Tab, dan jalur commit aman-reentrancy di belakang keduanya adalah bagian dari kontrol viewer interaktif yang dirilis bersama PDFlibPas, PDF library untuk Delphi dan C++Builder, berdampingan dengan sisa permukaan API rendering halaman, anotasi, dan field-form-nya