Artykuł techniczny

Reentrancja OnExit w edytorze pola formularza w miejscu w Delphi

Formant TPDFlibViewer w PDF Library for Delphi zatwierdza edytor pola formularza w miejscu przez własne zdarzenie OnExit tego edytora, a ta decyzja projektowa ukrywa klasyczną pułapkę Delphi VCL: ukrycie, zmiana rodzica lub zniszczenie formantu z fokusem z wnętrza jego własnego handlera OnExit może uruchomić OnExit po raz drugi, zanim pierwsze wywołanie wróci, wysyłając logikę zatwierdzenia z powrotem w samą siebie

Awaria, którą to produkuje, opiera się czystemu odtworzeniu. Użytkownik szybko przechodzi Tabem przez serię pól tekstowych na zeskanowanym formularzu aplikacyjnym, i co jakiś czas przeglądarka zgłasza naruszenie dostępu, albo gorzej, działa dalej, po cichu zapisując niewłaściwą wartość do pola dwa Taby wstecz. Odtwórz to na żądanie, a błąd wygląda oczywisty z perspektywy czasu; poluj na niego z jednego raportu awarii klienta, a wygląda jak duch, ponieważ to, czy drugi OnExit faktycznie się uruchomi, zależy od czasowania uchwytu okna i fokusu, które zmienia się w zależności od typu pola, szybkości pisania i wszystkiego innego, co robi w tej chwili kolejka komunikatów

Jak TPDFlibViewer kładzie prawdziwy edytor na wyrenderowaną stronę

TPDFlibViewer renderuje każdą stronę PDF do bitmapy i domyślnie nie zamienia pól formularza w żywe formanty VCL, więc BeginEditFormField to metoda łącząca te dwa światy: wywołana z indeksem pola, wyszukuje prostokąt pola i konwertuje go do współrzędnych klienta, a następnie kładzie prawdziwy TEdit lub TMemo na wierzchu tego prostokąta dla pola tekstowego, albo TComboBox w stylu csDropDownList dla pola wyboru, kompletnego z aktualną wartością pola już wczytaną. ISO 32000-2 §12.7 definiuje, czym jest pole formularza tekstowe lub wyboru wewnątrz PDF, ale nic w tej specyfikacji nie mówi, jak aplikacja Windows powinna pozwolić komuś w nie wpisać, i ta luka jest dokładnie tym, co ma wypełnić BeginEditFormField. Zarówno OnKeyDown, jak i OnExit są podłączone do tych samych dwóch metod przeglądarki, InplaceEditorKeyDown i InplaceEditorExit, na każdym edytorze, jaki tworzy TPDFlibViewer, parowanie niezmienione od czasu, gdy interaktywne wypełnianie formularzy po raz pierwszy pojawiło się w v3.220.0, a OnExit to miejsce, w którym zaczynają się kłopoty

Diagram TPDFlibViewer umieszczającego żywy edytor VCL nad bitmapą wyrenderowanej strony PDF i podłączającego OnKeyDown i OnExit do procedur przeglądarki
BeginEditFormField kładzie prawdziwą, focusowaną kontrolę na stronie, a każdy taki edytor przychodzi z OnExit — drzwiami, przez które wchodzi reentrancja

Dlaczego ukrycie edytora uruchamia OnExit po raz drugi?

TWinControl w VCL traktuje zmianę Visible lub Parent na formancie z fokusem jako powód, by natychmiast przenieść fokus z niego, a przeniesienie fokusu z formantu to dokładnie to, co uruchamia zdarzenie OnExit tego formantu, synchronicznie, zanim przypisanie właściwości, które to wyzwoliło, w ogóle wróci. CommitInplaceEditor, metoda, której PDF Library for Delphi używa do zamknięcia edytora w miejscu i zapisania jego wartości z powrotem do pola formularza, musi zrobić dokładnie te dwie rzeczy na wyjściu: ustawić Editor.Visible na False i ustawić Editor.Parent na nil, tak aby formant przestał rysować się na wierzchu strony i przestał odbierać dane wejściowe. Zrób którąkolwiek z nich, gdy edytor wciąż ma fokus, co niemal zawsze ma, ponieważ użytkownik właśnie go opuścił, a OnExit uruchamia się ponownie w środku tego samego wywołania, które miało być ostatnią rzeczą, jaką OnExit tego edytora kiedykolwiek wyzwoli

Co idzie nie tak, gdy CommitInplaceEditor wchodzi ponownie w samego siebie?

Naiwna metoda zatwierdzania płaci za to na jeden z dwóch sposobów. Albo zapisuje wartość pola dwukrotnie, raz z oryginalnego wywołania i raz z reentrantnego wywołania, które wśliznęło się, zanim pierwsze skończyło dotykać swojego własnego stanu, albo próbuje zwolnić formant edytora, podczas gdy ramka stosu leżąca dalej wciąż jest wewnątrz własnego handlera zdarzenia tego samego formantu, co jest niezdefiniowanym terytorium w VCL i objawia się jako naruszenie dostępu, które może wskazywać na niemal dowolną linię, niekoniecznie tę, która faktycznie to spowodowała. Żadna z tych awarii nie potrzebuje dużego formularza, żeby się uruchomić; dwupolowy dokument wystarczy, pod warunkiem że użytkownik opuszcza drugie pole wystarczająco szybko, by system operacyjny wciąż odwijał komunikaty fokusu z pierwszego

// Wersja naiwna: dobrze wygląda w recenzji, zawodzi dopiero przy realnej szybkości pisania
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // wciąż działa wewnątrz własnego OnExit FEditor
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // sfokusowany formant przepięty tutaj: OnExit
                               // uruchamia się ponownie, wchodząc rekurencyjnie w tę samą metodę
  FEditor.Free;                // zwolniony, podczas gdy wywołujący dalej w
  FEditor := nil;              // stosie wciąż jest wewnątrz jego handlera OnExit
end;

Wyzeruj odwołanie, zanim dotkniesz formantu

Poprawka wydana przez PDF Library for Delphi to jedno przełożenie kolejności: przechwyć edytor w zmiennej lokalnej, wyczyść pole, które na niego wskazuje, i dopiero potem zacznij zmieniać właściwości formantu. CommitInplaceEditor odczytuje FInplaceEditor do lokalnej zmiennej Editor, natychmiast ustawia FInplaceEditor na nil, i dopiero po tym przypisuje Editor.Visible i Editor.Parent. Reentrantne wywołanie wyzwolone przez którekolwiek z tych dwóch przypisań samo odczytuje FInplaceEditor, znajduje je już nil, i wychodzi na swojej pierwszej linii, zanim zdąży dotknąć Editor czy zapisać wartość pola po raz drugi

procedure TPDFlibViewer.CommitInplaceEditor(Save: Boolean);
var
  Editor: TWinControl;
begin
  Editor := FInplaceEditor;
  if not Assigned(Editor) then
    Exit;                      // wywołanie reentrantne trafia tutaj i się zatrzymuje
  FInplaceEditor := nil;       // odłącz, zanim formant zostanie w ogóle dotknięty
  if Save then
    SaveEditorValue(Editor);   // bezpieczne: FInplaceEditor jest już nil
  Editor.Visible := False;
  Editor.Parent := nil;        // może ponownie uruchomić OnExit; zabezpieczenie powyżej
                                // zamienia to reentrantne wywołanie w operację pustą
  ReapDeadEditor;               // zwolnij cokolwiek zaparkowano w poprzednim cyklu
  FDeadEditor := Editor;        // zaparkuj ten zamiast zwalniać go tutaj
end;

SaveEditorValue w tym listingu zastępuje prawdziwą gałąź, która sprawdza, czy Editor to TComboBox, TMemo, czy TEdit, i odczytuje jego wartość odpowiednio, ponieważ PDF Library for Delphi tworzy inny formant w zależności od tego, czy pole jest polem tekstowym, czy polem wyboru. Zabezpieczenie nie dba o to, która gałąź się uruchomi, tylko o to, żeby FInplaceEditor było nil, zanim wykona się cokolwiek zdolnego wyzwolić OnExit, co jest tym jednym ograniczeniem kolejności, które czyni resztę metody bezpieczną do napisania w dowolnym skądinąd naturalnym stylu

Nigdy nie zwalniaj formantu z wnętrza jego własnego zdarzenia

TPDFlibViewer.CommitInplaceEditor nigdy nie wywołuje Editor.Free bezpośrednio, i to celowe: zwolnienie formantu jest niebezpieczne, dopóki ramka stosu należąca do dyspozycji zdarzenia tego samego formantu może wciąż odwijać się nad wywołaniem, które go zwalnia, reentrantny OnExit czy nie. PDF Library for Delphi zamiast tego przekazuje odłączony edytor do jednoslotowego miejsca parkowania, FDeadEditor, zwalniając cokolwiek tam siedziało z poprzedniego cyklu edycji przez mały pomocnik, ReapDeadEditor, wywoływany na początku kolejnego BeginEditFormField i jeszcze raz z CloseDocument; każdy edytor, jaki tworzy przeglądarka, jest też własnością samej przeglądarki, TEdit.Create(Self), a nie TEdit.Create(nil), więc nawet formant wciąż zaparkowany w FDeadEditor, gdy przeglądarka zostaje zniszczona, jest sprzątany przez zwykłe posiadanie komponentu VCL, zamiast wyciekać

procedure TPDFlibViewer.ReapDeadEditor;
begin
  if Assigned(FDeadEditor) then
  begin
    FDeadEditor.Free;          // teraz bezpieczne: własny OnExit tego formantu
    FDeadEditor := nil;        // zakończył się co najmniej jeden cykl edycji temu
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // zapisz cokolwiek jeszcze otwartego w edytorze
  // ... wyszukiwanie pola i konwersja prostokąta pominięte ...
  ReapDeadEditor;              // teraz bezpiecznie zwolnić edytor zaparkowany w poprzednim cyklu
  Edit := TEdit.Create(Self);
  Edit.Parent := Self;
  Edit.OnExit := InplaceEditorExit;
  FInplaceEditor := Edit;
  FInplaceEditor.SetFocus;
  Result := 1;
end;

Dlaczego ujawnia się to najmocniej podczas szybkiej nawigacji Tabem?

FocusNextFormField, metoda, którą PDF Library for Delphi dodał w v3.226.0, aby napędzać nawigację Tab i Shift+Tab przez formularz, wywołuje BeginEditFormField dla następnego kwalifikującego się pola przy każdym pojedynczym skoku, a BeginEditFormField zaczyna od wywołania CommitInplaceEditor(True), aby wypłukać cokolwiek edytor poprzedniego pola zostawił otwarte. To oznacza, że każde naciśnięcie Tab, jakie wykonuje użytkownik wypełniający wielopolowy formularz, uruchamia dokładnie opisaną powyżej sekwencję odłącz-a-potem-dotknij raz, co jest dokładnie ścieżką kodu najbardziej prawdopodobną, by wciąż mieć formant naprawdę z fokusem w chwili, gdy Visible i Parent się zmieniają, ponieważ Tab to ta jedna interakcja niemal na pewno pozostawiająca wychodzący edytor trzymający fokus aż do chwili, gdy poprosi o niego nowy

Schemat blokowy PDF Library for Delphi pokazujący, jak ustawienie Editor.Parent na nil w edytorze z fokusem powoduje ponowne odpalenie OnExit, wchodząc ponownie w CommitInplaceEditor, zanim pierwsze wywołanie się zwróci
Pojedyncze przypisanie zmieniające rodzica wysyła zatwierdzenie z powrotem do samego siebie, otwierając zarówno tryb podwójnego zapisu, jak i przedwczesnego Free

Nic z tego nie czyni tego błędu niezawodnym do zademonstrowania, i warto to powiedzieć wprost, zamiast pomijać. To, czy dane przypisanie Visible lub Parent faktycznie wymusza synchroniczny OnExit, zależy od stanu fokusu i uchwytu okna, który debugger zmienia samym podłączeniem się, który niepowiązane przerysowanie czy timer mogą zaburzyć, i który zachowuje się inaczej w zależności od tego, który z TEdit, TMemo czy TComboBox akurat jest formantem w grze. Zabezpieczenie, które tylko czasem jest ćwiczone, jest powodem, dla którego ten rodzaj defektu przetrwa zarówno przegląd kodu, jak i testowanie ręczne, i jest też powodem, dla którego poprawka musi być poprawna z konstrukcji, zerując odwołanie, zanim cokolwiek innego się wydarzy, a nie poprawna dzięki temu, jakie zachowanie akurat zaobserwowała garstka ręcznych przebiegów testowych

Ogólny kształt tej poprawki sięga daleko poza jeden formant przeglądarki. Każda niestandardowa powierzchnia edycji zbudowana przez nałożenie żywego formantu VCL na wyrenderowaną zawartość, nie tylko pole formularza PDF, dziedziczy to samo zagrożenie w chwili, gdy jej logika zamknij-i-zatwierdź może zostać wyzwolona zarówno przez jawną akcję użytkownika, jak i przez niejawną zmianę fokusu, i ta sama dwuczęściowa odpowiedź się stosuje: wyczyść odwołanie identyfikujące aktywny formant, zanim zrobisz cokolwiek, co mogłoby wyzwolić jego własne zdarzenie wyjścia, i nigdy nie wywołuj Free ze ścieżki kodu, która wciąż mogłaby działać pod dyspozycją zdarzenia tego samego formantu. Szersza powierzchnia wypełniania formularzy i renderowania TPDFlibViewer, w tym to, jak decyduje, jaki typ formantu pokazać dla którego pola, jest omówiona w przeglądzie budowy interaktywnego formantu przeglądarki PDF w Delphi VCL za pomocą PDF Library for Delphi, a pamięć podręczna bitmapy strony, którą SetFormFieldValueAndRefresh musi unieważnić przy każdej zatwierdzonej edycji, jest omówiona osobno w artykule o dyskowej pamięci podręcznej stron przeglądarki dla DPI na monitor

PDF Library for Delphi: Uporządkowana sześciostopniowa sekwencja zatwierdzania, która czyści FInplaceEditor przed dotknięciem kontrolki, traktuje reentrancyjne OnExit jako no-op i odracza Free przez slot FDeadEditor
Wyzerowanie referencji najpierw sprawia, że każde reentranne wywołanie kończy się natychmiast, a slot zaparkowanego edytora przenosi każde Free do cyklu, w którym stosy są już ciche

Edycja pola formularza w miejscu, nawigacja po polach napędzana Tabem oraz bezpieczna wobec reentrancji ścieżka zatwierdzania stojąca za obiema są częścią interaktywnego formantu przeglądarki dostarczanego z PDF Library for Delphi, biblioteką PDF dla Delphi i C++Buildera, obok reszty jej powierzchni API renderowania stron, adnotacji i pól formularza