Artigo Técnico

Reentrância de OnExit em um Editor de Campo de Formulário Delphi no Local

O controle TPDFlibViewer do PDFlibPas confirma um editor de campo de formulário no local por meio do próprio evento OnExit do editor, e essa escolha de design esconde uma armadilha clássica da VCL do Delphi: esconder, reparentar, ou destruir um controle focado de dentro do próprio handler OnExit dele pode disparar OnExit uma segunda vez antes de a primeira chamada retornar, enviando a lógica de confirmação de volta para si mesma

A falha que isso produz resiste a uma reprodução limpa. Um usuário passa rapidamente pela tecla Tab por uma sequência de campos de texto em um formulário de aplicação digitalizado, e de vez em quando o visualizador levanta uma violação de acesso, ou pior, continua rodando enquanto silenciosamente escreve o valor errado em um campo dois tabs atrás. Reproduza sob demanda e o bug parece óbvio em retrospecto; persiga-o a partir do relatório de quebra de um único cliente e parece um fantasma, porque se o segundo OnExit de fato dispara depende de timing de handle de janela e foco que muda com o tipo de campo, velocidade de digitação, e qualquer outra coisa que a fila de mensagens esteja fazendo naquele instante

Como o TPDFlibViewer coloca um editor real por cima de uma página renderizada

TPDFlibViewer renderiza cada página de PDF para um bitmap e não transforma campos de formulário em controles VCL vivos por padrão, então BeginEditFormField é o método que faz a ponte entre os dois mundos: chamado com um índice de campo, ele busca o retângulo do campo e o converte para coordenadas de cliente, depois coloca um TEdit ou TMemo real por cima desse retângulo para um campo de texto, ou um TComboBox no estilo csDropDownList para um campo de escolha, completo com o valor atual do campo já carregado. A ISO 32000-2 §12.7 define o que é um campo de formulário de texto ou escolha dentro de um PDF, mas nada nessa especificação diz como uma aplicação Windows deveria deixar alguém digitar em um, e essa lacuna é exatamente o que BeginEditFormField existe para preencher. Tanto OnKeyDown quanto OnExit são conectados aos mesmos dois métodos do visualizador, InplaceEditorKeyDown e InplaceEditorExit, em todo editor que o TPDFlibViewer cria, um pareamento que vem inalterado desde que o preenchimento de formulário interativo apareceu pela primeira vez na v3.220.0, e OnExit é onde o problema começa

Por que esconder o editor dispara OnExit uma segunda vez?

TWinControl na VCL trata uma mudança em Visible ou Parent de um controle focado como um motivo para mover o foco para longe dele imediatamente, e mover o foco para longe de um controle é exatamente o que dispara o evento OnExit desse controle, de forma síncrona, antes mesmo de a atribuição de propriedade que o disparou retornar. CommitInplaceEditor, o método que o PDFlibPas usa para fechar o editor no local e escrever seu valor de volta no campo de formulário, precisa fazer precisamente essas duas coisas na saída: definir Editor.Visible como False e definir Editor.Parent como nil, para que o controle pare de desenhar por cima da página e pare de receber entrada. Faça qualquer uma dessas coisas enquanto o editor ainda tem foco, o que quase sempre acontece já que o usuário acabou de sair dele, e OnExit dispara novamente no meio da própria chamada que deveria ter sido a última coisa que o OnExit desse editor jamais disparou

O que dá errado quando o CommitInplaceEditor reentra em si mesmo?

Um método de confirmação ingênuo paga por isso de uma de duas formas. Ou escreve o valor do campo duas vezes, uma vez da chamada original e uma vez da chamada reentrante que se infiltrou antes de a primeira terminar de tocar seu próprio estado, ou tenta liberar o controle de editor enquanto um frame mais adiante na pilha de chamadas ainda está dentro do próprio handler de evento desse mesmo controle, que é território indefinido na VCL e aparece como uma violação de acesso que pode apontar para quase qualquer linha, não necessariamente a que de fato a causou. Nenhuma das duas falhas precisa de um formulário grande para disparar; um documento de dois campos é suficiente, desde que o usuário saia do segundo campo rápido o bastante para que o sistema operacional ainda esteja desenrolando mensagens de foco do primeiro

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

Anule a referência antes de tocar o controle

A correção que o PDFlibPas lança é um único reordenamento: capture o editor em uma variável local, limpe o campo que aponta para ele, e só então comece a mudar as propriedades do controle. CommitInplaceEditor lê FInplaceEditor em uma variável local Editor, define FInplaceEditor como nil imediatamente, e só depois atribui Editor.Visible e Editor.Parent. Uma chamada reentrante disparada por qualquer uma dessas duas atribuições lê o próprio FInplaceEditor, o encontra já nil, e sai em sua primeiríssima linha, antes de poder tocar Editor ou escrever o valor do campo uma segunda vez

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 nessa listagem representa o ramo real, que verifica se Editor é um TComboBox, um TMemo, ou um TEdit e lê seu valor de acordo, já que o PDFlibPas cria um controle diferente dependendo de o campo ser um campo de texto ou um campo de escolha. A proteção não se importa com qual ramo roda, apenas que FInplaceEditor seja nil antes de qualquer coisa capaz de disparar OnExit ser executada, que é a única restrição de ordenação que torna seguro escrever o resto do método em qualquer estilo que por outro lado seria natural

Nunca libere um controle de dentro de seu próprio evento

TPDFlibViewer.CommitInplaceEditor nunca chama Editor.Free diretamente, e isso é deliberado: liberar um controle é inseguro enquanto um frame de pilha pertencente a esse mesmo controle no despacho de seu próprio evento ainda pode estar se desenrolando acima da chamada que o libera, reentrante em OnExit ou não. O PDFlibPas, em vez disso, entrega o editor desanexado a um local de estacionamento de slot único, FDeadEditor, liberando o que quer que estivesse sentado ali do ciclo de edição anterior por meio de um pequeno auxiliar, ReapDeadEditor, chamado no início do próximo BeginEditFormField e mais uma vez de CloseDocument; todo editor que o visualizador cria também é possuído pelo próprio visualizador, TEdit.Create(Self) em vez de TEdit.Create(nil), de modo que mesmo um controle ainda estacionado em FDeadEditor quando o visualizador é destruído é varrido pela propriedade comum de componente VCL, em vez de vazar

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;

Por que isso aparece com mais força durante navegação rápida por Tab?

FocusNextFormField, o método que o PDFlibPas adicionou na v3.226.0 para conduzir a navegação Tab e Shift+Tab através de um formulário, chama BeginEditFormField para o próximo campo elegível a cada salto único, e BeginEditFormField abre chamando CommitInplaceEditor(True) para descarregar qualquer editor que o campo anterior tenha deixado aberto. Isso significa que cada pressão de Tab que um usuário faz enquanto preenche um formulário de múltiplos campos roda exatamente a sequência desanexar-depois-tocar descrita acima uma vez, que é precisamente o caminho de código mais provável de ainda ter um controle genuinamente focado no momento em que Visible e Parent mudam, porque Tab é a interação mais garantida de deixar o editor de saída segurando o foco até o novo pedir por ele

Nada disso torna o bug confiável de demonstrar, e vale a pena dizer isso claramente em vez de suavizar. Se uma determinada atribuição de Visible ou Parent de fato força um OnExit síncrono depende de estado de foco e handle de janela que um depurador muda só de estar anexado, que uma repintura ou timer não relacionado pode perturbar, e que se comporta diferente dependendo de qual entre TEdit, TMemo, ou TComboBox por acaso é o controle em jogo. Uma proteção que só às vezes é exercitada é o motivo pelo qual esse tipo de defeito sobrevive tanto à revisão de código quanto ao teste manual, e também é o motivo pelo qual a correção precisa estar correta por construção, anulando a referência antes de qualquer outra coisa acontecer, em vez de correta pelo comportamento que um punhado de passadas de teste manual por acaso observou

A forma geral dessa correção viaja bem além de um controle de visualizador. Qualquer superfície de edição personalizada construída sobrepondo um controle VCL vivo sobre conteúdo renderizado, não apenas um campo de formulário PDF, herda o mesmo risco no instante em que sua lógica de fechar-e-confirmar pode ser disparada tanto por uma ação explícita do usuário quanto por uma mudança de foco implícita, e a mesma resposta em duas partes se aplica: limpe a referência que identifica o controle ativo antes de fazer qualquer coisa que possa disparar seu próprio evento de saída, e nunca chame Free de um caminho de código que ainda possa estar rodando por baixo do despacho de evento do próprio controle. A superfície mais ampla de preenchimento de formulário e renderização do TPDFlibViewer, incluindo como ele decide qual tipo de controle mostrar para qual campo, é coberta em a visão geral de construir um controle de visualizador de PDF interativo em Delphi VCL com o PDFlibPas, e o cache de bitmap de página que SetFormFieldValueAndRefresh precisa invalidar a cada edição confirmada é coberto separadamente em a peça sobre o cache de página em disco por DPI de monitor do visualizador

Edição de campo de formulário no local, navegação de campo conduzida por Tab, e o caminho de confirmação seguro contra reentrância por trás de ambos fazem parte do controle de visualizador interativo lançado com o PDFlibPas, a biblioteca PDF para Delphi e C++Builder, ao lado do restante de sua superfície de API de renderização de página, anotação, e campo de formulário