Article technique

Réentrance OnExit dans un éditeur de formulaire sur place Delphi

Le contrôle TPDFlibViewer de PDFlibPas valide un éditeur de champ de formulaire sur place via le propre événement OnExit de l'éditeur, et ce choix de conception cache un piège classique du VCL Delphi : cacher, reparenter, ou détruire un contrôle ayant le focus depuis l'intérieur de son propre gestionnaire OnExit peut déclencher OnExit une seconde fois avant que le premier appel ne revienne, renvoyant la logique de validation en elle-même

L'échec que cela produit résiste à une reproduction propre. Un utilisateur tabule rapidement à travers une série de champs de texte sur un formulaire de demande scanné, et de temps en temps la visionneuse lève une violation d'accès, ou pire, continue de tourner tout en écrivant silencieusement la mauvaise valeur dans un champ deux tabulations en arrière. Reproduisez-le à la demande et le bogue paraît évident avec le recul ; traquez-le depuis le rapport de plantage d'un seul client et il ressemble à un fantôme, car le fait que le second OnExit se déclenche réellement dépend du timing de handle de fenêtre et de focus qui varie selon le type de champ, la vitesse de frappe, et tout ce que la file de messages fait d'autre à cet instant précis

Comment TPDFlibViewer place-t-il un véritable éditeur par-dessus une page rendue

TPDFlibViewer rend chaque page PDF en bitmap et ne transforme pas les champs de formulaire en contrôles VCL vivants par défaut, si bien que BeginEditFormField est la méthode qui relie les deux mondes : appelée avec un index de champ, elle recherche le rectangle du champ et le convertit en coordonnées client, puis dépose un véritable TEdit ou TMemo par-dessus ce rectangle pour un champ de texte, ou un TComboBox en style csDropDownList pour un champ de choix, complet avec la valeur actuelle du champ déjà chargée. ISO 32000-2 §12.7 définit ce qu'est un champ de formulaire de texte ou de choix à l'intérieur d'un PDF, mais rien dans cette spécification ne dit comment une application Windows devrait laisser quelqu'un y taper, et cet écart est précisément ce que BeginEditFormField existe pour combler. OnKeyDown et OnExit sont tous deux câblés aux deux mêmes méthodes de visionneuse, InplaceEditorKeyDown et InplaceEditorExit, sur chaque éditeur que TPDFlibViewer crée, un appariement qui n'a pas changé depuis que le remplissage de formulaire interactif a atterri pour la première fois en v3.220.0, et OnExit est là où les ennuis commencent

Pourquoi cacher l'éditeur déclenche-t-il OnExit une seconde fois ?

TWinControl dans le VCL traite un changement de Visible ou Parent sur un contrôle ayant le focus comme une raison de retirer immédiatement le focus de celui-ci, et retirer le focus d'un contrôle est exactement ce qui déclenche l'événement OnExit de ce contrôle, de façon synchrone, avant même que l'assignation de propriété qui l'a déclenché ne revienne. CommitInplaceEditor, la méthode que PDFlibPas utilise pour fermer l'éditeur sur place et réécrire sa valeur dans le champ de formulaire, doit faire précisément ces deux choses en sortant : régler Editor.Visible sur False et régler Editor.Parent sur nil afin que le contrôle cesse de se dessiner par-dessus la page et cesse de recevoir de l'entrée. Faites l'une ou l'autre pendant que l'éditeur a encore le focus, ce qui est presque toujours le cas puisque l'utilisateur vient de le quitter, et OnExit se déclenche à nouveau au milieu de l'appel même qui était censé être la dernière chose que l'OnExit de cet éditeur déclencherait jamais

Que se passe-t-il de travers quand CommitInplaceEditor se réentre lui-même ?

Une méthode de validation naïve paie pour cela de l'une des deux façons. Soit elle écrit la valeur du champ deux fois, une fois depuis l'appel original et une fois depuis l'appel réentrant qui s'est glissé avant que le premier n'ait fini de toucher son propre état, soit elle tente de libérer le contrôle éditeur pendant qu'une trame plus bas dans la pile d'appels est encore à l'intérieur du propre gestionnaire d'événement de ce même contrôle, ce qui est un territoire non défini dans le VCL et se manifeste comme une violation d'accès qui peut pointer vers presque n'importe quelle ligne, pas nécessairement celle qui l'a réellement causée. Aucun des deux échecs n'a besoin d'un grand formulaire pour se déclencher ; un document à deux champs suffit, à condition que l'utilisateur quitte le second champ assez vite pour que l'OS soit encore en train de dérouler les messages de focus du premier

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

Mettez la référence à nil avant de toucher au contrôle

La correction que PDFlibPas fournit est une simple réorganisation : capturer l'éditeur dans une variable locale, effacer le champ qui pointe vers lui, et seulement ensuite commencer à changer les propriétés du contrôle. CommitInplaceEditor lit FInplaceEditor dans une variable locale Editor, règle FInplaceEditor sur nil immédiatement, et n'assigne Editor.Visible et Editor.Parent qu'après cela. Un appel réentrant déclenché par l'une ou l'autre de ces deux assignations lit FInplaceEditor lui-même, le trouve déjà à nil, et sort à sa toute première ligne, avant de pouvoir toucher à Editor ou écrire la valeur du champ une seconde fois

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 dans ce listage tient lieu de la branche réelle, qui vérifie si Editor est un TComboBox, un TMemo, ou un TEdit et lit sa valeur en conséquence, puisque PDFlibPas crée un contrôle différent selon que le champ est un champ de texte ou un champ de choix. La protection ne se soucie pas de quelle branche s'exécute, seulement que FInplaceEditor soit à nil avant que quoi que ce soit capable de déclencher OnExit ne s'exécute, ce qui est la seule contrainte d'ordre qui rend le reste de la méthode sûr à écrire dans quel que soit le style par ailleurs naturel

Ne jamais libérer un contrôle depuis l'intérieur de son propre événement

TPDFlibViewer.CommitInplaceEditor n'appelle jamais Editor.Free directement, et c'est délibéré : libérer un contrôle est dangereux pendant qu'une trame de pile appartenant à la répartition d'événement de ce même contrôle pourrait encore être en train de se dérouler au-dessus de l'appel qui le libère, réentrance OnExit ou non. PDFlibPas transmet plutôt l'éditeur détaché à un emplacement de stationnement à un seul créneau, FDeadEditor, libérant ce qui était assis là depuis le cycle d'édition précédent via un petit assistant, ReapDeadEditor, appelé au début du prochain BeginEditFormField et une fois de plus depuis CloseDocument ; chaque éditeur que la visionneuse crée est aussi possédé par la visionneuse elle-même, TEdit.Create(Self) plutôt que TEdit.Create(nil), si bien que même un contrôle encore stationné dans FDeadEditor quand la visionneuse est détruite est balayé par la propriété de composant VCL ordinaire plutôt que de fuir

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;

Pourquoi cela se manifeste-t-il le plus fort pendant une navigation Tab rapide ?

FocusNextFormField, la méthode que PDFlibPas a ajoutée en v3.226.0 pour piloter la navigation Tab et Maj+Tab à travers un formulaire, appelle BeginEditFormField pour le prochain champ éligible à chaque saut, et BeginEditFormField s'ouvre en appelant CommitInplaceEditor(True) pour purger quel que soit l'éditeur que le champ précédent a laissé ouvert. Cela signifie que chaque pression de Tab qu'un utilisateur fait en remplissant un formulaire à plusieurs champs exécute exactement une fois la séquence détacher-puis-toucher décrite ci-dessus, ce qui est précisément le chemin de code le plus susceptible d'avoir encore un contrôle réellement au focus au moment où Visible et Parent changent, car Tab est l'interaction la plus certaine à laisser l'éditeur sortant garder le focus jusqu'à ce que le nouveau le demande

Rien de tout cela ne rend le bogue fiable à démontrer, et cela mérite d'être dit franchement plutôt que d'être glissé sous le tapis. Le fait qu'une assignation Visible ou Parent donnée force réellement un OnExit synchrone dépend de l'état de focus et de handle de fenêtre qu'un débogueur change juste en étant attaché, qu'un redessin ou un minuteur sans rapport peut perturber, et qui se comporte différemment selon lequel de TEdit, TMemo, ou TComboBox se trouve être le contrôle en jeu. Une protection qui n'est que parfois exercée est la raison pour laquelle ce genre de défaut survit à la fois à la relecture de code et aux tests manuels, et c'est aussi la raison pour laquelle la correction doit être correcte par construction, mettant la référence à nil avant que quoi que ce soit d'autre ne se produise, plutôt que correcte par quel que soit le comportement qu'une poignée de passes de test manuelles se sont trouvées observer

La forme générale de cette correction voyage bien au-delà d'un seul contrôle de visionneuse. Toute surface d'édition personnalisée construite en superposant un contrôle VCL vivant sur du contenu rendu, pas seulement un champ de formulaire PDF, hérite du même danger dès l'instant où sa logique de fermeture-et-validation peut être déclenchée à la fois par une action utilisateur explicite et un changement de focus implicite, et la même réponse en deux parties s'applique : effacer la référence qui identifie le contrôle actif avant de faire quoi que ce soit qui pourrait déclencher son propre événement de sortie, et ne jamais appeler Free depuis un chemin de code qui pourrait encore s'exécuter sous la propre répartition d'événement de ce contrôle. La surface plus large de remplissage de formulaire et de rendu de TPDFlibViewer, y compris comment il décide quel type de contrôle afficher pour quel champ, est couverte dans l'aperçu de la construction d'un contrôle de visionneuse PDF interactif en Delphi VCL avec PDFlibPas, et le cache de bitmap de page que SetFormFieldValueAndRefresh doit invalider à chaque édition validée est couvert séparément dans l'article sur le cache de page disque par DPI de moniteur de la visionneuse

L'édition de champ de formulaire sur place, la navigation de champ pilotée par Tab, et le chemin de validation sûr en réentrance derrière les deux font partie du contrôle de visionneuse interactif fourni avec PDFlibPas, la bibliothèque PDF pour Delphi et C++Builder, aux côtés du reste de sa surface d'API de rendu de page, d'annotation, et de champ de formulaire