Artigo Técnico

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

O controlo TPDFlibViewer do PDF Library for Delphi 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

Diagrama de TPDFlibViewer a colocar um editor VCL ativo sobre o bitmap da página PDF renderizada e a ligar OnKeyDown e OnExit aos handlers do visualizador
BeginEditFormField coloca um controlo real com foco por cima da página, e cada um desses editores vem de fábrica com OnExit — a porta por onde a reentrância entra

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 PDF Library for Delphi 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

// Versão ingénua: lê-se bem em revisão, só falha a velocidade real de digitação
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // ainda a correr dentro do próprio OnExit de FEditor
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // controlo com foco reparentado aqui: o OnExit
                               // dispara de novo, reentrando neste mesmo método
  FEditor.Free;                // libertado enquanto um chamador mais abaixo na
  FEditor := nil;              // pilha ainda está dentro do seu manipulador OnExit
end;

Anule a referência antes de tocar no controlo

A correção que o PDF Library for Delphi 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;                      // uma chamada reentrante aterra aqui e pára
  FInplaceEditor := nil;       // desliga antes de o controlo ser sequer tocado
  if Save then
    SaveEditorValue(Editor);   // seguro: FInplaceEditor já é nil
  Editor.Visible := False;
  Editor.Parent := nil;        // pode disparar OnExit de novo; a proteção acima
                                // transforma essa chamada reentrante num no-op
  ReapDeadEditor;               // liberta o que quer que tenha sido estacionado no ciclo anterior
  FDeadEditor := Editor;        // estaciona este em vez de o libertar aqui
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 PDF Library for Delphi 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 PDF Library for Delphi 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;          // seguro agora: o próprio OnExit deste controlo
    FDeadEditor := nil;        // terminou há pelo menos um ciclo de edição
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // esvazia qualquer editor que ainda esteja aberto
  // ... omitida a procura do campo e a conversão do retângulo ...
  ReapDeadEditor;              // agora é seguro libertar o editor estacionado do ciclo anterior
  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 PDF Library for Delphi 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

Fluxograma da PDF Library for Delphi mostrando como definir Editor.Parent como nil num editor com foco faz OnExit disparar novamente, reentrando em CommitInplaceEditor antes de a primeira chamada devolver
Uma única atribuição de reparenting devolve o commit a si próprio, abrindo tanto o modo de falha de escrita dupla como o de Free prematuro

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 PDF Library for Delphi, 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

PDF Library for Delphi: Sequência de commit ordenada em seis passos que limpa FInplaceEditor antes de tocar no controlo, trata OnExit reentrante como no-op, e adia o Free através do slot FDeadEditor
Anular primeiro a referência faz com que cada chamada reentrante saia imediatamente, e a ranhura do editor estacionado move cada Free para um ciclo cujas pilhas estão silenciosas

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 PDF Library for Delphi, 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