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 colgar para siempre, porque el hilo worker seguía bloqueado esperando por el callback que acababa de despachar. La corrección es dejar que el objeto de hilo cargue con la identidad del callback que está ejecutando en ese momento
¿Por qué se cuelga detener 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 por sí mismo. Llama a TThread.Synchronize, le pasa el método al hilo principal, y se bloquea hasta que el hilo principal lo ejecuta y señala la finalización. Eso es exactamente lo que quieres para un handler que recarga una grilla o repinta un formulario, y también es lo que convierte la llamada de desactivación obvia en una trampa
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
// Aquí estamos en el hilo principal, gracias a TThread.Synchronize
FBook.LoadFromFile(FMonitor.FileName);
RefreshGrid;
// El usuario quería una sola recarga, así que dejar de vigilar
FMonitor.Active := False; // solía no volver nunca
end;
Sigue las dos pilas y el ciclo es obvio. El hilo principal está dentro de SetActive(False), que termina al worker y después llama a Worker.WaitFor para asegurarse de que el hilo salió de verdad antes de liberar el objeto. El hilo worker, mientras tanto, está estacionado dentro de TThread.Synchronize, esperando a que el hilo principal vuelva del mismísimo handler que está llamando a SetActive. Ningún lado puede moverse. La aplicación no crashea, no lanza nada, simplemente deja de repintar, que es la firma de diagnóstico peor que un bug puede tener
El mismo deadlock tiene una segunda cara
Apagar la sincronización no evita el problema, solo lo reubica. Con SynchronizeEvent en False — la opción sensata en un servicio o una herramienta de consola sin message loop — TXLSFileMonitor invoca al handler directamente en el hilo de polling. Tu handler ahora corre en el worker, así que una llamada a Monitor.Active := False desde adentro llega a Worker.WaitFor en el propio hilo 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 afuera del callback. Ese supuesto se cumple para un botón Close o el destructor de un formulario y falla para el único call site que la gente busca primero, que es por qué sobrevivió tanto tiempo. Cualquier componente que a la vez sea dueño de 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 camino de teardown tiene que poder distinguir si se está ejecutando desde dentro de un callback que el hilo al que va a unirse todavía está sosteniendo
Que el hilo worker cargue con la identidad del callback
HotXLS lo resuelve registrando, en el propio objeto de hilo, qué hilo de OS está ejecutando el handler en ese momento. TXLSFileMonitorThread.FireEvent sella FCallbackThreadID con GetCurrentThreadId justo antes de invocar al handler y lo limpia en un bloque finally después. Ese único campo alcanza para que el camino de desactivación distinga «llamado desde código no relacionado» de «llamado desde dentro de mi propio callback», y funciona idéntico para 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 encolado 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 tres cosas en un orden deliberado. Desacopla el hilo del monitor limpiando FThread primero, para que GetActive reporte False desde ese instante y un Active := False reentrante dentro del mismo handler salga de inmediato en lugar de intentar tumbar el hilo dos veces. Llama a Terminate. Solo entonces decide cómo recuperar el objeto de hilo
Worker := TXLSFileMonitorThread(FThread);
FThread := nil; // el monitor está inactivo desde este punto
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
// Tanto los handlers directos como los sincronizados deben volver antes
// de que el worker pueda salir; se libera solo tras desenrollar el callback
Worker.FreeOnTerminate := True
else
begin
Worker.WaitFor;
Worker.Free;
end;
Cuando la identidad coincide, HotXLS nunca espera. Pone FreeOnTerminate := True y vuelve, dejando que el handler termine, que la llamada a Synchronize complete, que Execute observe Terminated, y que el RTL libere el objeto de hilo una vez que la pila del callback se desenrolló por completo. 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 la garantía dura de que ningún callback posterior puede dispararse después de que Active := False vuelva. Dos caminos de salida, un campo para elegir entre ellos. Una consecuencia merece ser dicha con todas las letras: en el camino de auto-liberación el worker sobrevive al handler. Nada en el camino de vuelta de FireEvent puede tocar el objeto del monitor otra vez, porque el handler tiene derecho a haberlo liberado. Por eso FireEvent guarda Owner y Handler en locales desde el principio y en la cláusula finally solo toca su propio campo. 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 revisar 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 drenando la cola de sincronización mientras se bloquea, justamente para que un hilo estacionado en Synchronize pueda completar y salir en lugar 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 todavía puede despacharse durante el teardown, después de que el monitor ya soltó su referencia a FThread. El guard al inicio de FireEvent convierte ese despacho en un no-op. Sin él, una aplicación que cierra su formulario principal mientras un guardado aterriza en el archivo vigilado puede correr un handler de recarga contra estado a medio disponer, y el 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: cada método sincronizado necesita una comprobación de Terminated como primera sentencia, no porque el hilo pueda haber desaparecido sino porque la cola sobrevive a la decisión de detenerse
La misma forma de bug en el camino de importación CSV
La auditoría que produjo el fix del monitor encontró un defecto hermano en el importador de texto, y vale la pena leerlo lado a lado porque el error de fondo es idéntico: una pieza de configuración que se consume como si fuera estado scratch. TXLSCSVLoader expone una propiedad SkipRows para las líneas de banner que van arriba del header real en los reportes exportados. El parser necesita una cuenta regresiva mientras recorre esas líneas, y la implementación original contaba hacia abajo sobre el propio campo de respaldo de la propiedad. Importa un archivo y SkipRows se volvió cero en silencio; reutiliza el mismo loader para un segundo archivo y las filas de banner aterrizaron en la grilla
Loader := TXLSCSVLoader.Create;
try
Loader.SkipRows := 2; // dos líneas de banner arriba del header
Loader.ParseFile('jan-export.csv');
ReadGrid(Loader);
// SkipRows sigue siendo 2 aquí: FSkipRemaining se reinicia por parse
Loader.ParseFile('feb-export.csv');
ReadGrid(Loader);
finally
Loader.Free;
end;
HotXLS ahora mantiene FSkipRows como el ajuste declarado y reinicia un contador FSkipRemaining separado al inicio de cada parse, tanto en el camino 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 había estado dispuesta a aceptar un prefijo parseable como fecha completa. Ahora valida el campo entero — largo, posiciones de separadores y cada dígito — para que un valor como una referencia de pedido que meramente empieza con algo con forma de fecha siga siendo texto en lugar de convertirse en un TDateTime equivocado. La coerción de tipos silenciosa al importar es mucho más cara de depurar río abajo que un campo rechazado, un tema que también recorre cómo HotXLS protege un workbook contra lectores y escritores concurrentes
Lo que este fix te da y lo que no te da
Seamos precisos con la garantía. El cambio de propiedad del hilo vuelve seguro exactamente un patrón que antes era 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 vuelve al componente seguro de manejar desde hilos arbitrarios
- Las escrituras de propiedades siguen sin ser thread-safe. Pon
FileName,IntervalMsySynchronizeEventdesde un solo hilo, normalmente el que creó el monitor IntervalMsaplica desde la siguiente activación, y el worker duerme en porciones cortas para queTerminatese atienda con prontitud incluso con un período de polling largo- La detección es por hora de última escritura más tamaño, así que un cambio que preserve ambos es invisible; un archivo que desaparece a mitad de guardado no dispara nada, y el evento llega una vez que el contenido nuevo está en su lugar
- Con
SynchronizeEventenFalsetu handler corre en el hilo worker, y cada regla sobre tocar controles VCL desde un hilo de fondo aplica a lo que sea que hagas adentro - Recargar un workbook desde el handler todavía necesita 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 todavía se está asentando, así que una carga que abre el archivo de inmediato puede encontrarse con un guardado a medio commit; reintentar una vez después de una pausa corta es más robusto que confiar en el primer sello. Si tu propio código es el que escribe el archivo vigilado, el archivo temporal por etapas y el rename atómico descritos en cómo HotXLS hace crash-safe cada guardado también le dan al monitor un evento limpio de una sola transición que observar en lugar de un flujo de tamaños intermedios. Y si varios watchers alimentan la misma rutina de recarga, las preocupaciones de serialización en el read gate alrededor de la inflación ZIP concurrente son lo siguiente a leer. La lección más amplia sobrevive al componente concreto. Cualquier clase que sea dueña de un hilo y llame de vuelta al código de usuario necesita un camino de teardown que pueda reconocer su propio marco de callback, porque el handler es el lugar más natural del mundo para que un usuario diga basta. 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