Artykuł techniczny

Reentrancja OnExit w edytorze pola formularza w miejscu w Delphi

Formant TPDFlibViewer w PDFlibPas 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

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 PDFlibPas 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

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

Wyzeruj odwołanie, zanim dotkniesz formantu

Poprawka wydana przez PDFlibPas 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;                      // 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 w tym listingu zastępuje prawdziwą gałąź, która sprawdza, czy Editor to TComboBox, TMemo, czy TEdit, i odczytuje jego wartość odpowiednio, ponieważ PDFlibPas 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. PDFlibPas zamiast tego przekazuje odłączony edytor do jednosloto­wego 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;          // 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;

Dlaczego ujawnia się to najmocniej podczas szybkiej nawigacji Tabem?

FocusNextFormField, metoda, którą PDFlibPas 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

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ą PDFlibPas, 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

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 PDFlibPas, biblioteką PDF dla Delphi i C++Buildera, obok reszty jej powierzchni API renderowania stron, adnotacji i pól formularza