Articolo tecnico

Deadlock del callback del file monitor HotXLS in Delphi

HotXLS osserva una cartella di lavoro su disco con TXLSFileMonitor e solleva OnFileChanged quando un altro processo salva sopra il file. Impostare Monitor.Active := False dall'interno di quel handler prima si bloccava per sempre, perché il worker thread era ancora fermo in attesa del callback che aveva appena spedito. Il fix è far portare all'oggetto thread l'identità del callback che sta eseguendo in quel momento

Perché fermare un file monitor dentro il suo stesso handler blocca tutto?

Perché la chiamata di disattivazione e il callback girano su due thread diversi, e ognuno aspetta che l'altro finisca. TXLSFileMonitor arriva con SynchronizeEvent impostato a True, il che significa che il thread di polling non chiama il tuo handler da solo. Chiama TThread.Synchronize, consegna il metodo al thread principale, e poi si blocca finché il thread principale non lo ha eseguito e segnalato il completamento. È esattamente quello che vuoi per un handler che ricarica una griglia o ridipinge una form, ed è anche ciò che rende una trappola la chiamata di disattivazione più ovvia

Due volti del deadlock del file monitor HotXLS: con SynchronizeEvent True il thread principale si blocca in Worker.WaitFor mentre il worker si blocca in TThread.Synchronize, e con False l'handler gira sul thread di polling così SetActive False aspetta il thread stesso, lasciando OnFileChanged incapace di tornare
Con SynchronizeEvent attivo, il thread principale aspetta in Worker.WaitFor mentre il worker aspetta in Synchronize; con esso spento, l'handler gira sul thread di polling e aspetta la propria uscita: un solo ciclo chiuso, due modalità di dispatch
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // Qui siamo sul thread principale, grazie a TThread.Synchronize
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // L'utente voleva un solo reload, quindi smetti di osservare
  FMonitor.Active := False;   // prima non tornava mai
end;

Traccia i due stack e il ciclo è ovvio. Il thread principale è dentro SetActive(False), che termina il worker e poi chiama Worker.WaitFor per essere sicuro che il thread sia davvero uscito prima che l'oggetto venga liberato. Il worker thread, nel frattempo, è parcheggiato dentro TThread.Synchronize, in attesa che il thread principale torni proprio dall'handler che sta chiamando SetActive. Nessuno dei due può muoversi. L'applicazione non crasha, non solleva nulla, semplicemente smette di ridipingere, che è la firma diagnostica peggiore che un bug possa avere

Lo stesso deadlock indossa una seconda faccia

Spegnere la sincronizzazione non evita il problema, lo sposta soltanto. Con SynchronizeEvent impostato a False — la scelta sensata in un servizio o in un tool da console senza message loop — TXLSFileMonitor invoca l'handler direttamente sul thread di polling. Il tuo handler ora gira sul worker, quindi una chiamata a Monitor.Active := False dall'interno raggiunge Worker.WaitFor sul worker thread stesso. Un thread non può aspettare la propria uscita, quindi si blocca finché qualcosa fuori non uccide il processo. Entrambe le varianti sono lo stesso bug: SetActive(False) assumeva di essere sempre chiamato da un punto esterno al callback. Quell'assunzione regge per un pulsante Close o il distruttore di una form e fallisce proprio nel call site che la gente prova per primo, ed è per questo che è sopravvissuto così a lungo. Qualsiasi componente che possiede un thread e vi spedisce codice utente eredita lo stesso pericolo, e la soluzione è la stessa in ogni caso: il percorso di teardown deve saper dire se sta girando dentro un callback che il thread che sta per fare join tiene ancora in mano

Far portare al worker thread l'identità del callback

HotXLS lo risolve registrando, sull'oggetto thread stesso, quale thread di sistema sta eseguendo in quel momento l'handler. TXLSFileMonitorThread.FireEvent timbra FCallbackThreadID con GetCurrentThreadId subito prima di invocare l'handler e lo azzera in un blocco finally dopo. Quel singolo campo basta al percorso di disattivazione per distinguere "chiamato da codice non correlato" da "chiamato dall'interno del mio stesso callback", e funziona identico per entrambe le modalità di dispatch, perché nel caso sincronizzato GetCurrentThreadId dentro FireEvent riporta già il thread principale

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // Un callback in coda può essere spedito da WaitFor durante la disattivazione
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // L'handler può aver distrutto Owner, ma questo thread resta vivo
    FCallbackThreadID := 0;
  end;
end;

Il ramo di disattivazione di SetActive fa poi tre cose in un ordine deliberato. Stacca il thread dal monitor azzerando prima FThread, così GetActive riporta False da quell'istante in poi e un Active := False rientrante dentro lo stesso handler esce subito invece di provare a smontare il thread due volte. Chiama Terminate. Solo dopo decide come recuperare l'oggetto thread

Come HotXLS risolve il teardown del file monitor: TXLSFileMonitorThread.FireEvent timbra FCallbackThreadID con GetCurrentThreadId prima di invocare l'handler e lo azzera in un blocco finally, così SetActive può staccare FThread, chiamare Terminate e scegliere tra l'auto-rilascio FreeOnTerminate dentro un callback e il classico WaitFor più Free
FireEvent timbra FCallbackThreadID prima di invocare l'handler e lo azzera in un blocco finally; SetActive poi sceglie tra FreeOnTerminate quando l'identità combacia e il classico WaitFor più Free quando il chiamante è estraneo
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // da questo punto il monitor è inattivo
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // Sia gli handler diretti sia quelli sincronizzati devono tornare prima che il worker
  // possa uscire; si rilascia da solo dopo che il callback si è svolto
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

Quando l'identità combacia, HotXLS non aspetta mai. Imposta FreeOnTerminate := True e torna, lasciando che l'handler finisca, che la chiamata Synchronize si completi, che Execute osservi Terminated e che la RTL liberi l'oggetto thread una volta che lo stack del callback si è del tutto svolto. Quando l'identità non combacia — un pulsante Close, un distruttore, un job in background — il classico WaitFor più Free resta la cosa giusta, perché ti dà la garanzia dura che nessun altro callback possa scattare dopo che Active := False torna. Due percorsi di uscita, un campo per scegliere tra loro. Una conseguenza merita di essere detta esplicitamente: sul percorso di auto-rilascio il worker sopravvive all'handler. Nulla nel percorso di ritorno di FireEvent può toccare di nuovo l'oggetto monitor, perché l'handler ha il diritto di averlo liberato. È per questo che FireEvent mette in cache Owner e Handler in locali all'inizio e nella clausola finally tocca solo il suo campo. Un FOwner.Something fuori posto dopo la chiamata all'handler trasformerebbe un deadlock sistemato in un use-after-free, che è un esito strettamente peggiore

Perché FireEvent deve controllare Terminated prima di fare qualsiasi cosa?

Perché WaitFor non è un'attesa passiva. Sul thread principale, l'implementazione RTL di TThread.WaitFor continua a svuotare la coda di sincronizzazione mentre si blocca, proprio perché un thread parcheggiato in Synchronize possa completare e uscire invece di andare in deadlock contro lo shutdown. È una feature, e significa che un callback accodato microsecondi prima che tu chiamassi Active := False da un pulsante Close può essere ancora spedito durante il teardown, dopo che il monitor ha già mollato il suo riferimento a FThread. La guardia in cima a FireEvent rende quella spedizione un no-op. Senza di essa, un'applicazione che chiude la sua form principale mentre un salvataggio sta atterrando sul file osservato può eseguire un handler di reload contro stato a metà smontaggio, e la conseguente access violation non somiglia affatto a un problema di ordine di shutdown. Se il tuo componente spedisce lavoro attraverso Synchronize, la regola si generalizza: ogni metodo sincronizzato ha bisogno di un controllo Terminated come prima istruzione, non perché il thread possa essere sparito ma perché la coda sopravvive alla decisione di fermarsi

La stessa forma di bug nel percorso di importazione CSV

L'audit che ha prodotto il fix del monitor ha tirato fuori un difetto fratello nell'importer di testo, e vale la pena leggerli fianco a fianco perché l'errore sottostante è identico: una parte di configurazione consumata come se fosse stato di lavoro. TXLSCSVLoader espone una proprietà SkipRows per le righe di banner che stanno sopra l'intestazione vera nei report esportati. Il parser ha bisogno di un countdown mentre lavora su quelle righe, e l'implementazione originale contava alla rovescia sul campo di backing della proprietà stessa. Importa un file e SkipRows diventava silenziosamente zero; riusa lo stesso loader per un secondo file e le righe di banner finivano nella griglia

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // due righe di banner sopra l'intestazione
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // Qui SkipRows è ancora 2: FSkipRemaining viene azzerato a ogni parse
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS ora tiene FSkipRows come impostazione dichiarata e azzera un contatore FSkipRemaining separato all'inizio di ogni parse, sia nel percorso delimitato sia in quello a larghezza fissa. La regola generale è economica da applicare e si ripaga da sola: se un loop lo decrementa, non è un campo di backing di proprietà. La stessa batch ha stretto anche XlsCsvTryParseISODateTime, che prima accettava un prefisso analizzabile come data intera. Ora valida l'intero campo — lunghezza, posizioni dei separatori e ogni cifra — così un valore come un riferimento d'ordine che inizia soltanto con qualcosa a forma di data resta testo invece di diventare un TDateTime sbagliato. La coercizione di tipo silenziosa in importazione è molto più costosa da debuggare a valle di un campo rifiutato, un tema che attraversa anche come HotXLS protegge una cartella di lavoro da lettori e scrittori concorrenti

Il difetto del loader CSV dietro l'audit HotXLS: contare SkipRows alla rovescia sul campo di backing della proprietà lo azzerava in silenzio dopo un parse, così un secondo file faceva filtrare le righe di banner nella griglia, mentre il modello corretto tiene FSkipRows come impostazione dichiarata e azzera un contatore FSkipRemaining separato all'inizio di ogni chiamata ParseFile
Contare alla rovescia sul campo di backing della proprietà trasforma la configurazione in stato di lavoro: il primo parse azzera SkipRows, mentre il modello corretto tiene FSkipRows dichiarata e azzera FSkipRemaining all'inizio di ogni parse

Cosa questo fix ti dà e cosa no

Sii preciso sulla garanzia. La modifica alla thread ownership rende sicuro esattamente un pattern prima fatale: disattivare o liberare una TXLSFileMonitor dal suo stesso handler OnFileChanged, in entrambe le modalità di dispatch. Non è un design di concorrenza generale, e non rende il componente sicuro da guidare da thread arbitrari

  • Le scritture delle proprietà restano non thread-safe. Imposta FileName, IntervalMs e SynchronizeEvent da un solo thread, normalmente quello che ha creato il monitor
  • IntervalMs vale dalla prossima attivazione, e il worker dorme a fette corte così Terminate viene onorato subito anche con un periodo di polling lungo
  • La rilevazione è per ora dell'ultima scrittura più dimensione, quindi un cambiamento che preserva entrambe è invisibile; un file che sparisce a metà salvataggio non scatta, e l'evento arriva quando il nuovo contenuto è al suo posto
  • Con SynchronizeEvent impostato a False il tuo handler gira sul worker thread, e ogni regola sul toccare controlli VCL da un thread in background vale per qualsiasi cosa tu faccia lì dentro
  • Ricaricare una cartella di lavoro dall'handler richiede comunque una disciplina sua, soprattutto se altro codice tiene un riferimento al libro che viene sostituito

In pratica l'handler di reload è la parte che merita più cura, non il monitor. Un evento di cambiamento di solito arriva mentre il processo che scrive sta ancora assestandosi, quindi un load che apre il file subito può incontrare un salvataggio a metà; riprovare una volta dopo un breve ritardo è più robusto che fidarsi della prima marca temporale. Se è il tuo codice a scrivere il file osservato, il file temporaneo a fasi e la rinomina atomica descritti in come HotXLS rende ogni salvataggio crash-safe danno anche al monitor un evento pulito a transizione singola da osservare invece di un flusso di dimensioni intermedie. E se più osservatori alimentano la stessa routine di reload, le preoccupazioni di serializzazione in il read gate attorno all'inflazione ZIP concorrente sono la prossima cosa da leggere. La lezione più ampia sopravvive al componente specifico. Qualsiasi classe che possiede un thread e richiama codice utente ha bisogno di un percorso di teardown che sappia riconoscere il proprio frame di callback, perché l'handler è il posto più naturale del mondo in cui un utente dice fermati. Registrare l'id del thread di callback costa un DWORD e trasforma un hang inspiegabile in una normale questione di lifetime dell'oggetto. Il file monitoring, i salvataggi atomici e l'importazione di testo arrivano tutti nella HotXLS Delphi Component per Delphi e C++Builder, senza configurazione extra per attivare alcunché