Artigo Técnico

Deadlock no callback do file monitor do HotXLS em Delphi

O HotXLS vigia um workbook em disco com o TXLSFileMonitor e dispara OnFileChanged quando outro processo grava por cima do ficheiro. Definir Monitor.Active := False a partir de dentro desse handler costumava encravar para sempre, porque a worker thread continuava bloqueada à espera do callback que acabara de despachar. A correção é deixar o objeto da thread carregar a identidade do callback que está a correr nesse momento

Porque é que parar um file monitor dentro do seu próprio handler encrava?

Porque a chamada de desativação e o callback estão a correr em duas threads diferentes, e cada uma está à espera de a outra terminar. O TXLSFileMonitor vem com o SynchronizeEvent a True, o que significa que a thread de polling não chama o seu handler sozinha. Chama o TThread.Synchronize, entrega o método à thread principal, e depois bloqueia até a thread principal o ter corrido e sinalizado a conclusão. É exatamente o que se quer para um handler que recarrega uma grid ou repinta um form, e é também o que torna a chamada de desativação óbvia uma armadilha

Duas faces de deadlock do file monitor do HotXLS: com SynchronizeEvent True a thread principal bloqueia em Worker.WaitFor enquanto a worker bloqueia em TThread.Synchronize, e com ele False o handler corre na thread de polling pelo que SetActive False espera pela própria thread, deixando OnFileChanged sem conseguir retornar
Com o SynchronizeEvent ligado, a thread principal espera em Worker.WaitFor enquanto a worker espera em Synchronize; com ele desligado, o handler corre na thread de polling e espera pela própria saída — um ciclo fechado, dois modos de dispatch
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // Estamos na thread principal aqui, cortesia do TThread.Synchronize
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // O utilizador só queria um reload, portanto deixa de vigiar
  FMonitor.Active := False;   // costumava nunca retornar
end;

Trace as duas stacks e o ciclo é óbvio. A thread principal está dentro de SetActive(False), que termina a worker e depois chama Worker.WaitFor para ter a certeza de que a thread saiu mesmo antes de o objeto ser libertado. A worker thread, entretanto, está estacionada dentro do TThread.Synchronize, à espera de a thread principal retornar do próprio handler que está a chamar SetActive. Nenhum dos lados se mexe. A aplicação não crasha, não levanta exceção, simplesmente deixa de repintar, que é a pior assinatura de diagnóstico que um bug pode ter

O mesmo deadlock tem uma segunda cara

Desligar a sincronização não evita o problema, apenas o realoca. Com o SynchronizeEvent a False — a escolha sensata num serviço ou numa ferramenta de consola sem message loop — o TXLSFileMonitor invoca o handler diretamente na thread de polling. O seu handler corre agora na worker, pelo que uma chamada a Monitor.Active := False a partir de dentro dele chega ao Worker.WaitFor na própria worker thread. Uma thread não pode esperar pela própria saída, portanto bloqueia até algo de fora matar o processo. As duas variantes são o mesmo bug: o SetActive(False) assumia que era sempre chamado a partir de algum sítio fora do callback. Esse pressuposto aguenta-se num botão Close ou num destrutor de form e falha no call site a que as pessoas chegam primeiro, que é o motivo por que sobreviveu tanto tempo. Qualquer componente que seja dono de uma thread e despache código de utilizador a partir dela herda o mesmo risco, e a resolução é a mesma em todos os casos — o path de teardown tem de conseguir distinguir se está a ser corrido a partir de dentro de um callback que a thread que vai fazer join ainda está a segurar

Deixar a worker thread carregar a identidade do callback

O HotXLS resolve-o ao registar, no próprio objeto da thread, qual a thread do SO que está a executar o handler naquele momento. O TXLSFileMonitorThread.FireEvent carimba o FCallbackThreadID com GetCurrentThreadId imediatamente antes de invocar o handler e limpa-o num bloco finally depois. Esse único campo chega para o path de desativação distinguir "chamado a partir de código não relacionado" de "chamado a partir do meu próprio callback", e funciona de forma idêntica nos dois modos de dispatch, porque no caso sincronizado o GetCurrentThreadId dentro do FireEvent já reporta a thread principal

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // Um callback em fila pode ser despachado pelo WaitFor durante a desativação
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // O handler pode ter destruído Owner, mas esta thread continua viva
    FCallbackThreadID := 0;
  end;
end;

O ramo de desativação do SetActive faz depois três coisas numa ordem deliberada. Desliga a thread do monitor ao limpar o FThread primeiro, para que o GetActive reporte False a partir desse instante e um Active := False reentrante dentro do mesmo handler saia imediatamente em vez de tentar derrubar a thread duas vezes. Chama o Terminate. Só depois decide como reclamar o objeto da thread

Como o HotXLS resolve o teardown do file monitor: o TXLSFileMonitorThread.FireEvent carimba FCallbackThreadID com GetCurrentThreadId antes de invocar o handler e limpa-o num bloco finally, para que o SetActive possa desligar FThread, chamar Terminate, e escolher entre a auto-libertação FreeOnTerminate dentro de um callback e o clássico WaitFor mais Free
O FireEvent carimba FCallbackThreadID antes de invocar o handler e limpa-o num bloco finally; o SetActive escolhe depois entre FreeOnTerminate quando a identidade casa e o clássico WaitFor mais Free quando o chamador não está relacionado
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // o monitor está inativo a partir deste ponto
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // Tanto os handlers diretos como os sincronizados têm de retornar antes de a worker
  // poder sair; ela liberta-se depois de o callback desenrolar
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

Quando a identidade casa, o HotXLS nunca espera. Define FreeOnTerminate := True e retorna, deixando o handler terminar, a chamada Synchronize completar, o Execute observar Terminated, e a RTL libertar o objeto da thread assim que a stack do callback estiver totalmente desenrolada. Quando a identidade não casa — um botão Close, um destrutor, um job em segundo plano — o clássico WaitFor mais Free continua a ser o certo, porque dá uma garantia dura de que nenhum callback posterior pode disparar depois de Active := False retornar. Dois paths de saída, um campo para escolher entre eles. Uma consequência merece ser explicitada: no path de auto-libertação a worker sobrevive ao handler. Nada no caminho de retorno do FireEvent pode voltar a tocar no objeto do monitor, porque o handler tem direito a tê-lo libertado. É por isso que o FireEvent coloca Owner e Handler em locals logo à partida e toca apenas no seu próprio campo na cláusula finally. Um FOwner.Something perdido depois da chamada ao handler transformaria um deadlock corrigido num use-after-free, que é um resultado estritamente pior

Porque é que o FireEvent tem de verificar Terminated antes de fazer seja o que for?

Porque o WaitFor não é uma espera passiva. Na thread principal, a implementação de TThread.WaitFor na RTL continua a esvaziar a fila de sincronização enquanto bloqueia, precisamente para que uma thread estacionada em Synchronize possa completar e sair em vez de entrar em deadlock contra o shutdown. Isso é uma funcionalidade, e significa que um callback posto em fila microssegundos antes de chamar Active := False a partir de um botão Close ainda pode ser despachado durante o teardown, depois de o monitor já ter largado a sua referência FThread. O guard no topo do FireEvent torna esse dispatch um no-op. Sem ele, uma aplicação que fecha o seu form principal enquanto um save aterra no ficheiro vigiado pode correr um handler de reload contra um estado meio eliminado, e a access violation resultante não se parece nada com um problema de ordem de shutdown. Se o seu componente despacha trabalho através de Synchronize, a regra generaliza: todo o método sincronizado precisa de uma verificação de Terminated como primeira instrução, não porque a thread possa ter desaparecido mas porque a fila sobrevive à decisão de parar

A mesma forma de bug no path de importação CSV

A auditoria que produziu a correção do monitor encontrou um defeito irmão no importador de texto, e vale a pena lê-lo lado a lado porque o erro subjacente é idêntico: uma peça de configuração a ser consumida como se fosse estado temporário. O TXLSCSVLoader expõe uma propriedade SkipRows para as linhas de banner que ficam por cima do header real em relatórios exportados. O parser precisa de uma contagem decrescente enquanto trabalha essas linhas, e a implementação original contava para baixo no próprio campo de apoio da propriedade. Importe um ficheiro e o SkipRows tornou-se silenciosamente zero; reutilize o mesmo loader para um segundo ficheiro e as linhas de banner aterraram na grid

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // duas linhas de banner por cima do header
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // SkipRows ainda é 2 aqui: FSkipRemaining é reposto por parse
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

O HotXLS mantém agora o FSkipRows como a definição declarada e repõe um contador FSkipRemaining separado no início de cada parse, tanto no path delimitado como no de largura fixa. A regra geral é barata de aplicar e paga-se a si própria: se um loop o decrementa, não é um campo de apoio de propriedade. A mesma leva apertou também o XlsCsvTryParseISODateTime, que estava disposto a aceitar um prefixo parsável como data completa. Agora valida o campo inteiro — comprimento, posições dos separadores e cada dígito — para que um valor como uma referência de encomenda que apenas começa com algo com forma de data continue texto em vez de se tornar um TDateTime errado. Coerção de tipo silenciosa na importação é muito mais cara de depurar a jusante do que um campo rejeitado, um tema que também percorre como o HotXLS protege um workbook contra leitores e escritores concorrentes

O defeito do loader CSV por trás da auditoria do HotXLS: contar SkipRows para baixo no campo de apoio da propriedade zerava-o silenciosamente depois de um parse, pelo que um segundo ficheiro deixava vazar linhas de banner para a grid, enquanto o modelo corrigido mantém FSkipRows como a definição declarada e repõe um contador FSkipRemaining separado no início de cada chamada a ParseFile
Contar para baixo no campo de apoio da propriedade transforma configuração em estado temporário: o primeiro parse zera SkipRows, enquanto o modelo corrigido mantém FSkipRows declarado e repõe FSkipRemaining no início de cada parse

O que esta correção dá e não dá

Seja preciso sobre a garantia. A mudança de thread ownership torna seguro exatamente um padrão anteriormente fatal: desativar ou libertar um TXLSFileMonitor a partir de dentro do seu próprio handler OnFileChanged, em qualquer dos modos de dispatch. Não é um design de concorrência geral, e não torna o componente seguro para ser conduzido a partir de threads arbitrárias

  • Os writes de propriedades continuam a não ser thread-safe. Defina FileName, IntervalMs e SynchronizeEvent a partir de uma thread, normalmente a que criou o monitor
  • O IntervalMs aplica-se a partir da próxima ativação, e a worker dorme em fatias curtas para que o Terminate seja honrado prontamente mesmo com um período de polling longo
  • A deteção é pela hora da última escrita mais o tamanho, pelo que uma alteração que preserve ambos é invisível; um ficheiro que desapareça a meio do save não dispara, e o evento chega assim que o conteúdo novo está no lugar
  • Com o SynchronizeEvent a False, o seu handler corre na worker thread, e todas as regras sobre tocar em controlos VCL a partir de uma thread em segundo plano aplicam-se ao que fizer lá dentro
  • Recarregar um workbook a partir do handler continua a precisar da sua própria disciplina, especialmente se outro código tiver uma referência ao livro que está a ser substituído

Na prática, o handler de reload é a parte que merece mais cuidado, não o monitor. Um evento de alteração costuma chegar enquanto o processo que escreve ainda está a assentar, pelo que um load que abra o ficheiro imediatamente pode encontrar um save parcialmente cometido; tentar outra vez depois de um pequeno atraso é mais robusto do que confiar no primeiro carimbo. Se o seu próprio código é o que escreve o ficheiro vigiado, o ficheiro temporário em fases e o rename atómico descritos em como o HotXLS torna cada save crash-safe também dão ao monitor um evento limpo de transição única para observar em vez de um rio de tamanhos intermédios. E se vários watchers alimentam a mesma rotina de reload, as preocupações de serialização em o read gate à volta da inflação ZIP concorrente são a próxima leitura. A lição mais ampla sobrevive ao componente específico. Qualquer classe que seja dona de uma thread e chame de volta código de utilizador precisa de um path de teardown que reconheça o seu próprio frame de callback, porque o handler é o sítio mais natural do mundo para um utilizador dizer para. Registar o id da thread de callback custa um DWORD e transforma um hang inexplicável numa pergunta vulgar de tempo de vida de objeto. File monitoring, saves atómicos e importação de texto vêm todos no HotXLS Delphi Component para Delphi e C++Builder, sem configuração extra para ativar qualquer um deles