Technický článek

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

Ovládací prvek TPDFlibViewer v PDF Library for Delphi 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á

Diagram TPDFlibViewer umisťující živý VCL editor nad bitmapu vykreslené strany PDF a napojující OnKeyDown a OnExit na handlery prohlížeče
BeginEditFormField položí na stránku skutečný fokusoovaný prvek a každý takový editor přichází s OnExit — dveřmi, kudy prochází reentrance

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 PDF Library for Delphi 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

// Naivní verze: při revizi vypadá v pořádku, selže jen při reálné rychlosti psaní
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // stále běží uvnitř vlastního OnExit ve FEditoru
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // zaostřený ovládací prvek zde přerodičován: OnExit se
                               // vyvolá znovu, vstoupí zpět do téže metody
  FEditor.Free;                // uvolněno, zatímco volající dál ve
  FEditor := nil;              // stacku je stále uvnitř svého obslužníku OnExit
end;

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

Oprava, kterou PDF Library for Delphi 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;                      // reentrantní volání sem přistane a zastaví se
  FInplaceEditor := nil;       // odpojit dřív, než se ovládacího prvku vůbec dotkneme
  if Save then
    SaveEditorValue(Editor);   // bezpečné: FInplaceEditor už je nil
  Editor.Visible := False;
  Editor.Parent := nil;        // může znovu vyvolat OnExit; ochrana výše
                                // promění toto reentrantní volání v no-op
  ReapDeadEditor;               // uvolní cokoli, co bylo zaparkováno minulý cyklus
  FDeadEditor := Editor;        // zaparkuje tento místo toho, aby ho zde uvolnil
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 PDF Library for Delphi 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. PDF Library for Delphi 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;          // teď bezpečné: vlastní OnExit tohoto ovládacího prvku
    FDeadEditor := nil;        // skončilo alespoň před jedním cyklem editace
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // vyprázdní jakýkoli editor, který je stále otevřený
  // ... vyhledání pole a konverze obdélníku vynechány ...
  ReapDeadEditor;              // teď bezpečné uvolnit editor zaparkovaný z minulého cyklu
  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 PDF Library for Delphi 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á

Vývojový diagram PDF Library for Delphi ukazující, jak nastavení Editor.Parent na nil u editoru s fokusem znovu spustí OnExit a znovuvstoupí do CommitInplaceEditor dříve, než se vrátí první volání
Jediné přiřazení změňující rodiče pošle commit zpět do sebe sama a otevře tak režimy selhání dvojitého zápisu i předčasného Free

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 PDF Library for Delphi, 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

PDF Library for Delphi: Uspořádaná šestikroková posloupnost commitu vynulující FInplaceEditor před dotykem ovládacího prvku, považující reentrantské OnExit za no-op a odkládající Free přes slot FDeadEditor
Zrušení reference nejprve na nil donutí každé reentrantní volání okamžitě skončit a slot odloženého editoru přesune každé Free do cyklu, jehož zásobníky jsou klidné

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 PDF Library for Delphi, knihovnou PDF pro Delphi a C++Builder, spolu se zbytkem plochy API pro vykreslování stránek, anotace, a pole formuláře