HotXLS следит за книгой на диске через TXLSFileMonitor и поднимает OnFileChanged, когда другой процесс сохраняется поверх файла. Установка Monitor.Active := False изнутри этого хендлера раньше вешала всё навечно, потому что воркер-поток всё ещё был заблокирован в ожидании колбэка, который только что разослал. Фикс — дать объекту потока нести идентичность колбэка, который он сейчас выполняет
Почему остановка файлового монитора внутри его собственного хендлера вешает программу?
Потому что вызов деактивации и колбэк исполняются на двух разных потоках, и каждый ждёт, когда закончит другой. TXLSFileMonitor поставляется с SynchronizeEvent в True, а это значит, что опрашивающий поток сам ваш хендлер не вызывает. Он зовёт TThread.Synchronize, отдаёт метод главному потоку и блокируется, пока главный не выполнит его и не просигналит о завершении. Ровно то, что нужно хендлеру, перезагружающему грид или перерисовывающему форму, — и ровно то, что превращает очевидный вызов деактивации в ловушку
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
// Мы в главном потоке, спасибо TThread.Synchronize
FBook.LoadFromFile(FMonitor.FileName);
RefreshGrid;
// Пользователь хотел только одну перезагрузку, поэтому перестаём следить
FMonitor.Active := False; // раньше никогда не возвращалось
end;
Пройдите по двум стекам — и цикл очевиден. Главный поток сидит внутри SetActive(False), который терминирует воркера, а затем зовёт Worker.WaitFor, чтобы убедиться, что поток реально вышел до освобождения объекта. Воркер-поток тем временем припаркован внутри TThread.Synchronize и ждёт, когда главный вернётся из того самого хендлера, который зовёт SetActive. Ни одна сторона не может сдвинуться. Приложение не падает, не бросает исключение, оно просто перестаёт перерисовываться — худшая диагностическая сигнатура, какую только может иметь баг
Тот же дедлок носит второе лицо
Отключение синхронизации не убирает проблему, оно лишь переносит её. С SynchronizeEvent в False — разумный выбор в сервисе или консольной утилите без message loop — TXLSFileMonitor вызывает хендлер прямо на опрашивающем потоке. Ваш хендлер теперь исполняется на воркере, так что вызов Monitor.Active := False изнутри доходит до Worker.WaitFor на самом воркер-потоке. Поток не может ждать собственного выхода, поэтому блокируется, пока кто-то снаружи не убьёт процесс. Оба варианта — один и тот же баг: SetActive(False) предполагал, что его всегда зовут откуда-то извне колбэка. Для кнопки Close или деструктора формы это допущение держится, а для единственного места вызова, к которому люди тянутся первым, — нет, поэтому оно и прожило так долго. Любой компонент, который и владеет потоком, и рассылает пользовательский код из него, наследует ту же опасность, и решение в каждом случае одинаковое: путь teardown обязан уметь отличать, что его исполняют изнутри колбэка, который всё ещё держит поток, к которому собираются присоединиться
Пусть воркер-поток несёт идентичность колбэка
HotXLS решает это записью на самом объекте потока, какой OS-поток сейчас исполняет хендлер. TXLSFileMonitorThread.FireEvent штампует FCallbackThreadID значением GetCurrentThreadId непосредственно перед вызовом хендлера и чистит его в finally после. Этого одного поля достаточно, чтобы путь деактивации отличал «вызвано из постороннего кода» от «вызвано из моего собственного колбэка», и работает оно одинаково в обоих режимах доставки, потому что в синхронизированном случае GetCurrentThreadId внутри FireEvent уже сообщает главный поток
procedure TXLSFileMonitorThread.FireEvent;
var
Owner: TXLSFileMonitor;
Handler: TNotifyEvent;
begin
// Поставленный в очередь колбэк может быть разослан WaitFor во время деактивации
if Terminated then
Exit;
Owner := FOwner;
Handler := Owner.FOnFileChanged;
if not Assigned(Handler) then
Exit;
FCallbackThreadID := GetCurrentThreadId;
try
Handler(Owner);
finally
// Хендлер мог уничтожить Owner, но этот поток остаётся жить
FCallbackThreadID := 0;
end;
end;
Ветка деактивации SetActive затем делает три вещи в продуманном порядке. Сначала отвязывает поток от монитора, обнуляя FThread, так что GetActive с этой секунды сообщает False, а повторный Active := False внутри того же хендлера выходит немедленно, вместо того чтобы дважды сносить поток. Затем зовёт Terminate. И только потом решает, как утилизировать объект потока
Worker := TXLSFileMonitorThread(FThread);
FThread := nil; // монитор неактивен с этой точки
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
// И прямой, и синхронизированный хендлеры обязаны вернуться до выхода
// воркера; он освобождает себя после разматывания колбэка
Worker.FreeOnTerminate := True
else
begin
Worker.WaitFor;
Worker.Free;
end;
При совпадении идентичности HotXLS не ждёт никогда. Он ставит FreeOnTerminate := True и возвращается, позволяя хендлеру доиграть, вызову Synchronize завершиться, Execute заметить Terminated, а RTL освободить объект потока после полного разматывания стека колбэка. Когда идентичность не совпадает — кнопка Close, деструктор, фоновая задача, — классические WaitFor плюс Free по-прежнему правильный выбор, потому что дают жёсткую гарантию: после возврата Active := False ни один колбэк больше не сработает. Два пути выхода, одно поле для выбора между ними. Одно следствие стоит проговорить: на пути самоосвобождения воркер переживает хендлер. Ничто в пути возврата FireEvent не должно больше трогать объект монитора, потому что хендлер вправе был его освободить. Именно поэтому FireEvent кэширует Owner и Handler в локальные переменные заранее и в finally трогает только своё поле. Случайный FOwner.Something после вызова хендлера превратил бы исправленный дедлок в use-after-free, а это строго худший исход
Почему FireEvent обязан проверять Terminated до любых действий?
Потому что WaitFor — не пассивное ожидание. В главном потоке реализация TThread.WaitFor в RTL продолжает дренировать очередь синхронизации, пока блокируется, — ровно для того, чтобы поток, припаркованный в Synchronize, мог завершиться и выйти, а не дедлочить против выключения. Это фича, и она означает, что колбэк, поставленный в очередь за микросекунды до вызова Active := False из кнопки Close, всё ещё может быть разослан во время teardown — уже после того, как монитор выбросил ссылку FThread. Гард в начале FireEvent превращает такую рассылку в no-op. Без него приложение, закрывающее главную форму, пока на наблюдаемый файл ложится сохранение, может прогнать хендлер перезагрузки против наполовину утилизированного состояния, и получившийся access violation выглядит совсем не как проблема порядка выключения. Если ваш компонент рассылает работу через Synchronize, правило обобщается: каждый синхронизированный метод нуждается в проверке Terminated первым оператором — не потому, что поток может исчезнуть, а потому, что очередь переживает решение остановиться
Та же форма бага в пути импорта CSV
Аудит, породивший фикс монитора, вскрыл братский дефект в текстовом импортёре, и его стоит читать рядом, потому что лежащая в основе ошибка идентична: кусок конфигурации потребляется так, будто это черновое состояние. TXLSCSVLoader выставляет свойство SkipRows для баннерных строк над настоящим заголовком в экспортных отчётах. Парсеру нужен обратный отсчёт, пока он пробирается через эти строки, и исходная реализация считала вниз на самом бэкинг-поле свойства. Импортируйте один файл — и SkipRows молча стал нулём; переиспользуйте тот же лоадер для второго файла — и баннерные строки легли в грид
Loader := TXLSCSVLoader.Create;
try
Loader.SkipRows := 2; // две баннерные строки над заголовком
Loader.ParseFile('jan-export.csv');
ReadGrid(Loader);
// SkipRows здесь всё ещё 2: FSkipRemaining сбрасывается на каждом парсинге
Loader.ParseFile('feb-export.csv');
ReadGrid(Loader);
finally
Loader.Free;
end;
HotXLS теперь держит FSkipRows как объявленную настройку и сбрасывает отдельный счётчик FSkipRemaining в начале каждого парсинга — и в делимом, и в фиксированно-ширинном путях. Общее правило дёшево в применении и окупается: если цикл его декрементит — это не бэкинг-поле свойства. Та же партия затянула и XlsCsvTryParseISODateTime, который раньше был готов принять разбираемый префикс за целую дату. Теперь он валидирует всё поле — длину, позиции разделителей и каждую цифру, — так что значение вроде ссылки на заказ, лишь начинающееся с чего-то похожего на дату, остаётся текстом, а не превращается в неверный TDateTime. Молчаливое приведение типа на импорте отлаживать дальше по потоку куда дороже, чем отклонённое поле, — тема, которая проходит и через то, как HotXLS защищает книгу от конкурентных читателей и писателей
Что этот фикс даёт, а чего не даёт
Будьте точны насчёт гарантии. Изменение владения потоком делает безопасным ровно один ранее фатальный паттерн: деактивацию или освобождение TXLSFileMonitor изнутри его собственного хендлера OnFileChanged в любом из режимов доставки. Это не общий дизайн конкурентности, и оно не делает компонент безопасным для управления с произвольных потоков
- Запись свойств по-прежнему не thread-safe. Ставьте
FileName,IntervalMsиSynchronizeEventс одного потока — обычно с того, что создал монитор IntervalMsдействует со следующей активации, а воркер спит короткими порциями, чтобыTerminateвыполнялся оперативно даже при длинном периоде опроса- Детекция идёт по времени последней записи плюс размеру, так что изменение, сохраняющее оба значения, невидимо; файл, исчезнувший посреди сохранения, не срабатывает, а событие приходит, когда новое содержимое уже на месте
- С
SynchronizeEventвFalseваш хендлер исполняется на воркер-потоке, и все правила про касание VCL-контролов из фонового потока распространяются на то, что вы делаете внутри него - Перезагрузка книги из хендлера по-прежнему требует своей дисциплины, особенно если другой код держит ссылку на заменяемую книгу
На практике именно хендлер перезагрузки заслуживает максимум заботы, а не монитор. Событие изменения обычно приходит, пока пишущий процесс ещё устаканивается, так что загрузка, открывающая файл немедленно, может встретить наполовину закоммиченное сохранение; одна повторная попытка после короткой паузы надёжнее, чем вера в первую метку. Если наблюдаемый файл пишет ваш собственный код, поэтапный temp-файл и атомарный rename, описанные в том, как HotXLS делает каждое сохранение crash-safe, дают монитору чистое событие одного перехода для наблюдения вместо потока промежуточных размеров. А если несколько наблюдателей кормят одну рутину перезагрузки, следующим к прочтению будет про проблемы сериализации в read gate вокруг конкурентной ZIP-инфляции. Более широкий урок переживает конкретный компонент. Любому классу, который владеет потоком и колбэчит в пользовательский код, нужен путь teardown, умеющий распознавать собственный фрейм колбэка, потому что хендлер — самое естественное в мире место, где пользователь скажет «стоп». Запись идентичности потока колбэка стоит один DWORD и превращает необъяснимое зависание в обычный вопрос времени жизни объекта. Файловый мониторинг, атомарные сохранения и текстовый импорт поставляются в HotXLS Delphi Component для Delphi и C++Builder, и ни для чего из этого не нужна дополнительная конфигурация