Artículo técnico

Deadlock del callback del File Monitor de HotXLS en Delphi

HotXLS vigila un workbook en disco con TXLSFileMonitor y dispara OnFileChanged cuando otro proceso guarda sobre el archivo. Poner Monitor.Active := False desde dentro de ese handler solía colgarse para siempre, porque el worker thread seguía bloqueado esperando al callback que acababa de despachar. El arreglo es dejar que el objeto del thread lleve la identidad del callback que está ejecutando en ese momento

¿Por qué se cuelga parar un file monitor dentro de su propio handler?

Porque la llamada de desactivación y el callback corren en dos hilos distintos, y cada uno espera a que el otro termine. TXLSFileMonitor viene con SynchronizeEvent en True, lo que significa que el hilo de polling no llama a tu handler él mismo. Llama a TThread.Synchronize, entrega el método al hilo principal, y se bloquea hasta que el hilo principal lo ha ejecutado y ha señalado la finalización. Eso es exactamente lo que quieres para un handler que recarga un grid o repinta un formulario, y también es lo que convierte la llamada de desactivación obvia en una trampa

Dos caras del deadlock del file monitor de HotXLS: con SynchronizeEvent en True el hilo principal se bloquea en Worker.WaitFor mientras el worker se bloquea en TThread.Synchronize, y con él en False el handler corre en el hilo de polling, así que SetActive False espera al propio hilo y deja a OnFileChanged sin poder devolver
Con SynchronizeEvent activado, el hilo principal espera en Worker.WaitFor mientras el worker espera en Synchronize; con él desactivado, el handler corre en el hilo de polling y espera su propia salida — un ciclo cerrado, dos modos de despacho
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // Estamos en el hilo principal, cortesía de TThread.Synchronize
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // El usuario solo quería una recarga, así que deja de vigilar
  FMonitor.Active := False;   // solía no devolver nunca
end;

Traza las dos pilas y el ciclo es obvio. El hilo principal está dentro de SetActive(False), que termina el worker y luego llama a Worker.WaitFor para asegurarse de que el hilo ha salido de verdad antes de liberar el objeto. El worker thread, mientras tanto, está aparcado dentro de TThread.Synchronize, esperando a que el hilo principal vuelva del mismo handler que está llamando a SetActive. Ningún lado puede moverse. La aplicación no crashea, no lanza excepción, simplemente deja de repintar, que es la firma de diagnóstico más mala que puede tener un bug

El mismo deadlock tiene una segunda cara

Apagar la sincronización no evita el problema, solo lo reubica. Con SynchronizeEvent en False — la elección sensata en un servicio o una herramienta de consola sin message loop — TXLSFileMonitor invoca el handler directamente en el hilo de polling. Tu handler ahora corre en el worker, así que una llamada a Monitor.Active := False desde dentro llega a Worker.WaitFor en el propio hilo del worker. Un hilo no puede esperar su propia salida, así que se bloquea hasta que algo externo mate el proceso. Las dos variantes son el mismo bug: SetActive(False) asumía que siempre se llamaba desde fuera del callback. Esa suposición se cumple para un botón Close o un destructor de formulario y falla en el único punto de llamada al que la gente acude primero, que es por lo que sobrevivió tanto tiempo. Cualquier componente que a la vez posea un hilo y despache código de usuario desde él hereda el mismo peligro, y la resolución es la misma en todos los casos — el path de teardown tiene que poder distinguir si se está ejecutando desde dentro de un callback que el hilo que va a esperar sigue sosteniendo

Dejar que el worker thread lleve la identidad del callback

HotXLS lo resuelve registrando, en el propio objeto del hilo, qué hilo del SO está ejecutando el handler en ese momento. TXLSFileMonitorThread.FireEvent marca FCallbackThreadID con GetCurrentThreadId justo antes de invocar el handler y lo limpia después en un bloque finally. Ese único campo basta para que el path de desactivación distinga entre "llamado desde código no relacionado" y "llamado desde dentro de mi propio callback", y funciona igual en los dos modos de despacho, porque en el caso sincronizado GetCurrentThreadId dentro de FireEvent ya reporta el hilo principal

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // Un callback en cola puede ser despachado por WaitFor durante la desactivación
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // El handler puede haber destruido a Owner, pero este hilo sigue vivo
    FCallbackThreadID := 0;
  end;
end;

La rama de desactivación de SetActive hace entonces tres cosas en un orden deliberado. Desacopla el hilo del monitor limpiando FThread primero, así que GetActive reporta False desde ese instante y un Active := False reentrante dentro del mismo handler sale al momento en vez de intentar derribar el hilo dos veces. Llama a Terminate. Solo entonces decide cómo reclamar el objeto del hilo

Cómo resuelve HotXLS el teardown del file monitor: TXLSFileMonitorThread.FireEvent marca FCallbackThreadID con GetCurrentThreadId antes de invocar el handler y lo limpia en un bloque finally, así que SetActive puede desacoplar FThread, llamar a Terminate y elegir entre la auto-liberación FreeOnTerminate dentro de un callback y el clásico WaitFor más Free
FireEvent marca FCallbackThreadID antes de invocar el handler y lo limpia en un bloque finally; SetActive elige entonces entre FreeOnTerminate cuando la identidad coincide y el clásico WaitFor más Free cuando el llamador no tiene relación
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // el monitor queda inactivo desde este punto
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // Tanto los handlers directos como los sincronizados deben devolver antes
  // de que el worker pueda salir; se libera solo tras desenrollarse el callback
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

Cuando la identidad coincide, HotXLS nunca espera. Pone FreeOnTerminate := True y devuelve, dejando que el handler termine, que la llamada a Synchronize se complete, que Execute observe Terminated, y que el RTL libere el objeto del hilo una vez que la pila del callback se ha desenrollado del todo. Cuando la identidad no coincide — un botón Close, un destructor, un trabajo en segundo plano — el clásico WaitFor más Free sigue siendo lo correcto, porque te da una garantía dura de que ningún callback más puede dispararse después de que Active := False devuelva. Dos paths de salida, un campo para elegir entre ellos. Una consecuencia merece decirse con todas las letras: en el path de auto-liberación, el worker sobrevive al handler. Nada en el path de retorno de FireEvent puede tocar el objeto del monitor otra vez, porque el handler tiene derecho a haberlo liberado. Por eso FireEvent cachea Owner y Handler en locales desde el principio y solo toca su propio campo en la cláusula finally. Un FOwner.Something suelto después de la llamada al handler convertiría un deadlock arreglado en un use-after-free, que es un resultado estrictamente peor

¿Por qué FireEvent debe comprobar Terminated antes de hacer nada?

Porque WaitFor no es una espera pasiva. En el hilo principal, la implementación del RTL de TThread.WaitFor sigue vaciando la cola de sincronización mientras se bloquea, precisamente para que un hilo aparcado en Synchronize pueda completar y salir en vez de deadlockear contra el apagado. Eso es una feature, y significa que un callback encolado microsegundos antes de que llamaras a Active := False desde un botón Close puede seguir despachándose durante el teardown, después de que el monitor ya haya soltado su referencia a FThread. El guard al principio de FireEvent convierte ese despacho en un no-op. Sin él, una aplicación que cierra su formulario principal mientras un guardado está cayendo sobre el archivo vigilado puede ejecutar un handler de recarga contra un estado medio liberado, y la access violation resultante no se parece en nada a un problema de orden de apagado. Si tu propio componente despacha trabajo por Synchronize, la regla se generaliza: todo método sincronizado necesita un chequeo de Terminated como primera sentencia, no porque el hilo pueda haber desaparecido, sino porque la cola sobrevive a la decisión de parar

La misma forma de bug en el path de importación CSV

La auditoría que produjo el arreglo del monitor encontró un defecto hermano en el importador de texto, y merece la pena leerlo en paralelo porque el error subyacente es idéntico: una pieza de configuración consumida como si fuera estado de trabajo. TXLSCSVLoader expone una propiedad SkipRows para las líneas de banner que están encima de la cabecera real en los informes exportados. El parser necesita una cuenta atrás mientras recorre esas líneas, y la implementación original descontaba sobre el propio campo de respaldo de la propiedad. Importa un archivo y SkipRows se volvía cero en silencio; reutiliza el mismo loader para un segundo archivo y las filas de banner aterrizaban en el grid

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // dos líneas de banner sobre la cabecera
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // SkipRows sigue siendo 2 aquí: FSkipRemaining se resetea por parse
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS ahora mantiene FSkipRows como el ajuste declarado y resetea un contador FSkipRemaining aparte al inicio de cada parse, tanto en el path delimitado como en el de ancho fijo. La regla general es barata de aplicar y se paga sola: si un bucle lo decrementa, no es un campo de respaldo de propiedad. El mismo lote también apretó XlsCsvTryParseISODateTime, que estaba dispuesto a aceptar un prefijo parseable como fecha completa. Ahora valida el campo entero — longitud, posiciones de separador y cada dígito — para que un valor como una referencia de pedido que solo empieza con algo con forma de fecha siga siendo texto en vez de convertirse en un TDateTime equivocado. La coerción de tipos silenciosa en la importación es mucho más cara de depurar aguas abajo que un campo rechazado, un tema que también recorre cómo protege HotXLS un workbook contra lectores y escritores concurrentes

El defecto del loader CSV detrás de la auditoría de HotXLS: descontar SkipRows sobre el campo de respaldo de la propiedad lo ponía a cero en silencio tras un parse, así que un segundo archivo filtraba filas de banner al grid, mientras que el modelo corregido mantiene FSkipRows como el ajuste declarado y resetea un contador FSkipRemaining aparte al inicio de cada llamada a ParseFile
Descontar sobre el campo de respaldo de la propiedad convierte la configuración en estado de trabajo: el primer parse pone SkipRows a cero, mientras que el modelo corregido mantiene FSkipRows declarado y resetea FSkipRemaining al inicio de cada parse

Qué te da este arreglo y qué no

Sé preciso sobre la garantía. El cambio de thread ownership hace seguro exactamente un patrón antes fatal: desactivar o liberar un TXLSFileMonitor desde dentro de su propio handler OnFileChanged, en cualquiera de los dos modos de despacho. No es un diseño de concurrencia general, y no hace que el componente sea seguro de manejar desde hilos arbitrarios

  • Las escrituras de propiedades siguen sin ser thread-safe. Pon FileName, IntervalMs y SynchronizeEvent desde un solo hilo, normalmente el que creó el monitor
  • IntervalMs se aplica desde la siguiente activación, y el worker duerme en tramos cortos para que Terminate se respete con prontitud incluso con un periodo de poll largo
  • La detección es por hora de última escritura más tamaño, así que un cambio que preserve ambas cosas es invisible; un archivo que desaparece a mitad de guardado no dispara el evento, y este llega una vez que el contenido nuevo está en su sitio
  • Con SynchronizeEvent en False tu handler corre en el worker thread, y todas las reglas sobre tocar controles VCL desde un hilo de fondo se aplican a lo que hagas dentro
  • Recargar un workbook desde el handler sigue necesitando su propia disciplina, sobre todo si otro código tiene una referencia al libro que se está reemplazando

En la práctica, el handler de recarga es la parte que merece más cuidado, no el monitor. Un evento de cambio suele llegar mientras el proceso que escribe sigue asentándose, así que una carga que abra el archivo de inmediato puede encontrarse un guardado a medio commitear; reintentar una vez tras una pequeña espera es más robusto que fiarse del primer sello. Si tu propio código es el que escribe el archivo vigilado, el archivo temporal por fases y el rename atómico descritos en cómo hace HotXLS que cada guardado sea crash-safe también dan al monitor un evento limpio de transición única que observar en vez de un chorro de tamaños intermedios. Y si varios watchers alimentan la misma rutina de recarga, las preocupaciones de serialización de el read gate alrededor de la inflación ZIP concurrente son lo siguiente que leer. La lección amplia sobrevive al componente concreto. Cualquier clase que posea un hilo y llame de vuelta a código de usuario necesita un path de teardown que pueda reconocer su propio frame de callback, porque el handler es el lugar más natural del mundo para que un usuario diga para. Registrar el id del hilo del callback cuesta un DWORD y convierte un cuelgue inexplicable en una pregunta ordinaria de ciclo de vida de objetos. El file monitoring, los guardados atómicos y la importación de texto viajan todos en el HotXLS Delphi Component para Delphi y C++Builder, sin configuración extra para activar nada de eso