Technický článek

Reentrance OnExit ve vestavěném editoru pole formuláře v Delphi

Ovládací prvek TPDFlibViewer v PDFlibPas potvrzuje editor pole formuláře na místě přes vlastní událost OnExit tohoto editoru, a tato návrhová volba skrývá klasickou past VCL v Delphi: skrytí, přerodičovství, nebo zničení zaostřeného ovládacího prvku zevnitř jeho vlastní obsluhy OnExit dokáže vyvolat OnExit podruhé ještě předtím, než se první volání vrátí, a poslat logiku potvrzení zpátky do sebe sama

Selhání, které to vyprodukuje, odolává čisté reprodukci. Uživatel rychle prochází tabulátorem přes běh textových polí na naskenovaném přihlašovacím formuláři, a čas od času prohlížeč vyhodí access violation, nebo hůř, pokračuje v běhu, zatímco tiše zapisuje špatnou hodnotu do pole o dvě políčka zpátky. Reprodukujte to na požádání a chyba vypadá se zpětným pohledem zjevně; honte ji z jediné zprávy o pádu od zákazníka a vypadá jako duch, protože zda se druhé OnExit skutečně vyvolá, závisí na časování handle okna a fokusu, které se posouvá podle typu pole, rychlosti psaní, a čehokoli jiného, co fronta zpráv v tu chvíli dělá

Jak TPDFlibViewer umístí skutečný editor navrch vykreslené stránky

TPDFlibViewer vykresluje každou stránku PDF do bitmapy a ve výchozím stavu neproměňuje pole formuláře na živé ovládací prvky VCL, takže BeginEditFormField je metoda, která přemosťuje oba světy: zavolaná s indexem pole, vyhledá obdélník pole a převede jej na souřadnice klienta, pak umístí navrch tohoto obdélníku skutečný TEdit nebo TMemo pro textové pole, nebo TComboBox ve stylu csDropDownList pro pole s výběrem, kompletní s aktuální hodnotou pole už načtenou. ISO 32000-2 §12.7 definuje, co je textové nebo výběrové pole formuláře uvnitř PDF, ale nic v této specifikaci neříká, jak by aplikace Windows měla někomu dovolit do něj psát, a tato mezera je přesně to, co má BeginEditFormField vyplnit. Jak OnKeyDown, tak OnExit jsou zapojeny na stejné dvě metody prohlížeče, InplaceEditorKeyDown a InplaceEditorExit, na každém editoru, který TPDFlibViewer vytvoří, párování, které se dodává nezměněné od doby, kdy interaktivní vyplňování formulářů poprvé přistálo ve v3.220.0, a OnExit je místo, kde problém začíná

Proč skrytí editoru vyvolá OnExit podruhé?

TWinControl ve VCL bere změnu Visible nebo Parent na zaostřeném ovládacím prvku jako důvod okamžitě přesunout fokus pryč od něj, a přesunutí fokusu pryč od ovládacího prvku je přesně to, co vyvolá jeho událost OnExit, synchronně, ještě předtím, než se přiřazení vlastnosti, které to vyvolalo, vůbec vrátí. CommitInplaceEditor, metoda, kterou PDFlibPas používá k zavření editoru na místě a zápisu jeho hodnoty zpátky do pole formuláře, potřebuje udělat přesně tyto dvě věci cestou ven: nastavit Editor.Visible na False a nastavit Editor.Parent na nil, aby se ovládací prvek přestal kreslit navrch stránky a přestal přijímat vstup. Udělejte kteroukoli z nich, zatímco editor pořád má fokus, což skoro vždy má, protože uživatel jej právě opustil, a OnExit se vyvolá znovu uprostřed přesně toho volání, které mělo být poslední věcí, jakou OnExit tohoto editoru kdy vyvolal

Co se pokazí, když CommitInplaceEditor vstoupí sám do sebe znovu?

Naivní metoda potvrzení za to platí jedním ze dvou způsobů. Buď zapíše hodnotu pole dvakrát, jednou z původního volání a jednou z reentrantního volání, které se vplížilo dřív, než první stihlo dokončit dotyk vlastního stavu, nebo se pokusí uvolnit ovládací prvek editoru, zatímco rámec dál ve stacku volání je pořád uvnitř vlastní obsluhy události tohoto stejného ovládacího prvku, což je nedefinované území ve VCL a projeví se jako access violation, které dokáže ukázat téměř na jakýkoli řádek, ne nutně na ten, který to skutečně způsobil. Ani jedno selhání nepotřebuje velký formulář ke spuštění; dvoupolní dokument stačí, za předpokladu, že uživatel opustí druhé pole dost rychle na to, aby OS pořád odvíjel zprávy fokusu od prvního

// 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;

Vynulujte referenci dřív, než se dotknete ovládacího prvku

Oprava, kterou PDFlibPas dodává, je jediné přeuspořádání: zachytit editor do lokální proměnné, vyčistit pole, které na něj ukazuje, a teprve pak začít měnit vlastnosti ovládacího prvku. CommitInplaceEditor přečte FInplaceEditor do lokální proměnné Editor, okamžitě nastaví FInplaceEditor na nil, a teprve poté přiřadí Editor.Visible a Editor.Parent. Reentrantní volání vyvolané kterýmkoli z těchto dvou přiřazení přečte FInplaceEditor samo, najde jej už jako nil, a skončí na svém úplně prvním řádku, dřív, než se dotkne Editor nebo zapíše hodnotu pole podruhé

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 v tomto výpisu zastupuje skutečnou větev, která kontroluje, zda je Editor TComboBox, TMemo, nebo TEdit, a čte jeho hodnotu podle toho, protože PDFlibPas vytváří odlišný ovládací prvek podle toho, zda je pole textové nebo výběrové. Ochraně nezáleží na tom, která větev běží, jen na tom, že FInplaceEditor je nil dřív, než se spustí cokoli schopné vyvolat OnExit, což je to jediné omezení pořadí, které dělá zbytek metody bezpečným napsat v jakémkoli stylu, který je jinak přirozený

Nikdy neuvolňujte ovládací prvek zevnitř jeho vlastní události

TPDFlibViewer.CommitInplaceEditor nikdy nevolá Editor.Free přímo, a to je záměrné: uvolnění ovládacího prvku je nebezpečné, zatímco se rámec stacku patřící témuž ovládacímu prvku možná pořád odvíjí nad voláním, které jej uvolňuje, reentrantní OnExit nebo ne. PDFlibPas místo toho podá odpojený editor jedinému parkovacímu místu, FDeadEditor, uvolní cokoli, co tam sedělo z předchozího cyklu editace, přes malou pomocnou funkci, ReapDeadEditor, volanou na začátku dalšího BeginEditFormField a ještě jednou z CloseDocument; každý editor, který prohlížeč vytvoří, také vlastní sám prohlížeč, TEdit.Create(Self) místo TEdit.Create(nil), takže i ovládací prvek pořád zaparkovaný ve FDeadEditor, když se prohlížeč zničí, se zamete obyčejným vlastnictvím komponent VCL místo úniku

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;

Proč se to projeví nejtvrději při rychlé navigaci Tabem?

FocusNextFormField, metoda, kterou PDFlibPas přidal ve v3.226.0 k řízení navigace Tab a Shift+Tab napříč formulářem, volá BeginEditFormField pro další způsobilé pole při každém jednotlivém skoku, a BeginEditFormField začíná voláním CommitInplaceEditor(True), aby vyprázdnil jakýkoli editor, který předchozí pole ponechalo otevřený. To znamená, že každé stisknutí Tab, které uživatel udělá při vyplňování vícepolního formuláře, jednou spustí přesně sekvenci odpojit-pak-dotknout se popsanou výše, což je přesně ta cesta kódu nejpravděpodobnější, že ovládací prvek bude skutečně zaostřen v okamžiku, kdy se Visible a Parent změní, protože Tab je ta jedna interakce skoro zaručeně ponechávající odcházející editor s fokusem přesně do chvíle, kdy si o něj nový požádá

Nic z tohoto nedělá chybu spolehlivou pro demonstraci, a to stojí za přímé vyslovení místo přehlížení. Zda dané přiřazení Visible nebo Parent skutečně vynutí synchronní OnExit, závisí na stavu fokusu a handle okna, který debugger změní jen tím, že je připojený, který dokáže narušit nesouvisející překreslení nebo časovač, a který se chová odlišně podle toho, který z TEdit, TMemo, nebo TComboBox je právě ten ovládací prvek ve hře. Ochrana, která se jen občas skutečně procvičí, je důvod, proč tento druh defektu přežije jak code review, tak ruční testování; je to také důvod, proč oprava musí být správná konstrukčně, vynulováním reference dřív, než se stane cokoli jiného, místo správné podle toho, jaké chování náhodou pozorovala hrstka ručních testovacích průchodů

Obecný tvar této opravy cestuje daleko za jeden ovládací prvek prohlížeče. Jakákoli vlastní editační plocha postavená vrstvením živého ovládacího prvku VCL na vykreslený obsah, ne jen pole formuláře PDF, dědí stejné nebezpečí v okamžiku, kdy její logika zavřít-a-potvrdit může být vyvolána jak explicitní akcí uživatele, tak implicitní změnou fokusu, a platí stejná dvoudílná odpověď: vyčistit referenci, která identifikuje aktivní ovládací prvek, dřív, než se udělá cokoli, co by mohlo vyvolat jeho vlastní událost exit, a nikdy nevolat Free z cesty kódu, která by pořád mohla běžet pod vlastním dispatchem události tohoto ovládacího prvku. Širší plocha vyplňování formulářů a vykreslování TPDFlibViewer, včetně toho, jak rozhoduje, který typ ovládacího prvku ukázat pro které pole, je popsána v přehledu stavby interaktivního ovládacího prvku prohlížeče PDF ve VCL Delphi s PDFlibPas, a cache bitmapy stránky, kterou SetFormFieldValueAndRefresh musí invalidovat při každé potvrzené úpravě, je popsána samostatně v textu o diskové cache stránek prohlížeče na monitor s DPI

Editace pole formuláře na místě, navigace polí řízená Tabem, a cesta potvrzení bezpečná proti reentranci za oběma jsou součástí interaktivního ovládacího prvku prohlížeče dodávaného s PDFlibPas, knihovnou PDF pro Delphi a C++Builder, spolu se zbytkem plochy API pro vykreslování stránek, anotace, a pole formuláře