Artigo Técnico

Reentrância do OnExit num Editor de Campo de Formulário In-Place em Delphi

O controlo TPDFlibViewer do PDFlibPas confirma um editor de campo de formulário in-place através do próprio evento OnExit do editor, e essa escolha de desenho esconde uma armadilha clássica da VCL do Delphi: esconder, reatribuir o pai, ou destruir um controlo com foco de dentro do seu próprio manipulador de OnExit pode disparar o OnExit uma segunda vez antes de a primeira chamada regressar, reenviando a lógica de confirmação de volta para si própria

A falha que isto produz resiste a uma reprodução limpa. Um utilizador percorre rapidamente com Tab uma sequência de campos de texto num formulário de candidatura digitalizado, e de vez em quando o visualizador gera uma violação de acesso, ou pior, continua a correr enquanto silenciosamente escreve o valor errado num campo dois tabs atrás. Reproduza-se a pedido e o erro parece óbvio em retrospetiva; persiga-se a partir do relatório de crash de um único cliente e parece um fantasma, porque se o segundo OnExit efetivamente dispara depende de temporização de handle de janela e de foco que muda consoante o tipo de campo, a velocidade de digitação, e o que mais a fila de mensagens esteja a fazer nesse instante

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

O TPDFlibViewer renderiza cada página de PDF para um bitmap e não transforma os campos de formulário em controlos VCL ativos por predefinição, pelo que o BeginEditFormField é o método que faz a ponte entre os dois mundos: chamado com um índice de campo, procura o retângulo do campo e converte-o para coordenadas de cliente, e depois coloca um verdadeiro TEdit ou TMemo por cima desse retângulo para um campo de texto, ou uma TComboBox no estilo csDropDownList para um campo de escolha, já com o valor atual do campo carregado. A ISO 32000-2 §12.7 define o que é um campo de formulário de texto ou de escolha dentro de um PDF, mas nada nessa especificação diz como uma aplicação Windows deve deixar alguém escrever num, e essa lacuna é exatamente o que o BeginEditFormField existe para preencher. Tanto o OnKeyDown como o OnExit estão ligados aos mesmos dois métodos do visualizador, InplaceEditorKeyDown e InplaceEditorExit, em cada editor que o TPDFlibViewer cria, um emparelhamento que se distribui sem alterações desde que o preenchimento interativo de formulários chegou pela primeira vez na v3.220.0, e é no OnExit que o problema começa

Porque é que esconder o editor dispara o OnExit uma segunda vez?

O TWinControl na VCL trata uma alteração a Visible ou Parent num controlo com foco como razão para afastar o foco dele de imediato, e afastar o foco de um controlo é exatamente o que dispara o evento OnExit desse controlo, de forma síncrona, antes mesmo de a atribuição de propriedade que o despoletou regressar. O CommitInplaceEditor, o método que o PDFlibPas usa para fechar o editor in-place e escrever de volta o seu valor no campo de formulário, precisa de fazer precisamente essas duas coisas à saída: definir Editor.Visible como False e Editor.Parent como nil, para que o controlo pare de desenhar por cima da página e pare de receber entrada. Ao fazer qualquer uma dessas coisas enquanto o editor ainda tem o foco, o que quase sempre acontece já que o utilizador acabou de o deixar, o OnExit dispara de novo a meio da própria chamada que era suposto ser a última coisa que o OnExit desse editor alguma vez despoletaria

O que corre mal quando o CommitInplaceEditor reentra em si próprio?

Um método de confirmação ingénuo paga por isto de uma de duas formas. Ou escreve o valor do campo duas vezes, uma a partir da chamada original e outra a partir da chamada reentrante que se infiltrou antes de a primeira terminar de tocar no seu próprio estado, ou tenta libertar o controlo de editor enquanto uma frame mais abaixo na pilha de chamadas ainda está dentro do próprio manipulador de eventos desse controlo, o que é território indefinido na VCL e manifesta-se como uma violação de acesso que pode apontar para quase qualquer linha, não necessariamente a que efetivamente a causou. Nenhuma das falhas precisa de um formulário grande para se despoletar; um documento de dois campos basta, desde que o utilizador deixe o segundo campo suficientemente depressa para o SO ainda estar a desenrolar 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 no controlo

A correção que o PDFlibPas distribui é uma simples reordenação: capturar o editor numa variável local, limpar o campo que aponta para ele, e só depois começar a alterar as propriedades do controlo. O CommitInplaceEditor lê FInplaceEditor para uma variável local Editor, define FInplaceEditor como nil de imediato, e só depois atribui Editor.Visible e Editor.Parent. Uma chamada reentrante despoletada por qualquer uma dessas duas atribuições lê o próprio FInplaceEditor, encontra-o já como nil, e sai logo na sua primeira linha, antes de conseguir tocar em 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;

O SaveEditorValue nessa listagem representa o ramo real, que verifica se Editor é uma TComboBox, uma TMemo, ou uma TEdit e lê o seu valor em conformidade, já que o PDFlibPas cria um controlo diferente consoante o campo seja um campo de texto ou um campo de escolha. A proteção não se importa com qual o ramo que corre, apenas que FInplaceEditor seja nil antes de qualquer coisa capaz de despoletar o OnExit ser executada, que é a única restrição de ordem que torna o resto do método seguro de escrever em qualquer estilo de resto natural

Nunca liberte um controlo de dentro do seu próprio evento

O TPDFlibViewer.CommitInplaceEditor nunca chama Editor.Free diretamente, e isso é deliberado: libertar um controlo é inseguro enquanto uma frame de pilha pertencente ao próprio despacho de eventos desse controlo possa ainda estar a desenrolar-se acima da chamada que o liberta, reentrante ou não. O PDFlibPas entrega antes o editor destacado a um local de estacionamento de uma única posição, FDeadEditor, libertando o que quer que lá estivesse do ciclo de edição anterior através de um pequeno auxiliar, ReapDeadEditor, chamado no início do BeginEditFormField seguinte e mais uma vez a partir de CloseDocument; cada editor que o visualizador cria também é possuído pelo próprio visualizador, TEdit.Create(Self) em vez de TEdit.Create(nil), pelo que mesmo um controlo ainda estacionado em FDeadEditor quando o visualizador é destruído é apanhado pela propriedade de componente VCL comum em vez de gerar uma fuga

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;

Porque é que isto se manifesta com mais força durante a navegação rápida com Tab?

O FocusNextFormField, o método que o PDFlibPas acrescentou na v3.226.0 para conduzir a navegação Tab e Shift+Tab ao longo de um formulário, chama BeginEditFormField para o próximo campo elegível em cada salto individual, e o BeginEditFormField começa chamando CommitInplaceEditor(True) para descarregar qualquer que seja o editor que o campo anterior tenha deixado aberto. Isso significa que cada pressão de Tab que um utilizador faz ao preencher um formulário de vários campos corre exatamente uma vez a sequência de destacar-depois-tocar acima descrita, precisamente o caminho de código com mais probabilidade de ainda ter um controlo genuinamente com foco no momento em que Visible e Parent mudam, porque o Tab é a interação mais garantidamente capaz de deixar o editor de saída a segurar o foco até o novo o pedir

Nada disto torna o erro fiável de demonstrar, e vale a pena dizê-lo claramente em vez de o disfarçar. Se uma dada atribuição de Visible ou Parent efetivamente força um OnExit síncrono depende de estado de foco e de handle de janela que um depurador altera só por estar anexado, que uma repintura ou temporizador não relacionados podem perturbar, e que se comporta de forma diferente consoante qual de TEdit, TMemo, ou TComboBox calhe ser o controlo em jogo. Uma proteção que só às vezes é exercitada é a razão pela qual este tipo de defeito sobrevive tanto à revisão de código como aos testes manuais, e é também a razão pela qual a correção tem de estar correta por construção, anulando a referência antes de qualquer outra coisa acontecer, em vez de correta em função do comportamento que um punhado de passagens de teste manual calhou observar

A forma geral desta correção viaja muito para além de um único controlo de visualizador. Qualquer superfície de edição personalizada construída sobrepondo um controlo VCL ativo a conteúdo renderizado, não apenas um campo de formulário de PDF, herda o mesmo risco no momento em que a sua lógica de fechar-e-confirmar pode ser despoletada tanto por uma ação explícita do utilizador como por uma alteração implícita de foco, e a mesma resposta em duas partes aplica-se: limpar a referência que identifica o controlo ativo antes de fazer qualquer coisa que possa despoletar o seu próprio evento de saída, e nunca chamar Free a partir de um caminho de código que ainda possa estar a correr por baixo do próprio despacho de eventos desse controlo. A superfície mais ampla de preenchimento de formulários e renderização do TPDFlibViewer, incluindo como decide que tipo de controlo mostrar para que campo, é abordada em a visão geral sobre construir um controlo de visualizador de PDF interativo em Delphi VCL com o PDFlibPas, e a cache de bitmap de página que o SetFormFieldValueAndRefresh tem de invalidar em cada edição confirmada é abordada separadamente em a peça sobre a cache de página em disco por DPI de monitor do visualizador

A edição de campo de formulário in-place, a navegação de campo orientada por Tab, e o caminho de confirmação seguro contra reentrância por trás de ambos fazem parte do controlo de visualizador interativo distribuído com o PDFlibPas, a biblioteca de PDF para Delphi e C++Builder, a par do resto da sua superfície de API de renderização de página, anotação, e campo de formulário