Articolo tecnico

HotXLS, CRC32 di zlib-ng e Stack Overflow nei Thread Delphi

HotXLS può mandare in crash un worker thread Delphi senza alcuna eccezione catturabile quando calcola il checksum di una parte XML di foglio di lavoro di grandi dimensioni in un'unica chiamata: zlib-ng passa al proprio algoritmo Chorba sopra circa 119 KB di input, e la variante C generica di quell'algoritmo alloca un array di scratch abbastanza grande da far esplodere lo stack predefinito di 1 MB del thread. Delphi non ha mai la possibilità di reagire, perché uno stack overflow non è il tipo di eccezione che try/except è stato costruito per catturare

HotXLS è una libreria nativa Delphi e C++Builder per leggere e scrivere cartelle di lavoro Excel, e il crash è stato ricondotto al proprio scrittore di fogli di lavoro. Il primo segnale del problema è stato un ticket di supporto: un job di esportazione notturno andava in crash circa due volte a settimana, sempre a metà esecuzione, senza alcuna finestra di dialogo di eccezione Delphi e senza alcun errore registrato, solo un processo che spariva e una voce di Windows Error Reporting che non indicava nulla di utile. Riprodurlo alla scrivania era tutt'altra faccenda. I file di piccole dimensioni si salvavano bene. Anche i file di grandi dimensioni si salvavano bene, purché il salvataggio venisse eseguito sul thread principale con un debugger già collegato. Ci è voluto un vero lotto di file di dimensioni produttive che passasse attraverso il vero percorso di esportazione multi-thread per riportare il crash a casa, momento in cui l'I/O del disco, la pressione di memoria e un template sospetto erano già stati tutti esclusi

Come un salvataggio di foglio di lavoro diventa un'unica gigantesca chiamata CRC32

I file XLSX sono contenitori ZIP, e il formato ZIP richiede un checksum CRC-32 per ogni voce, registrato sia nell'intestazione locale del file sia nella directory centrale. HotXLS calcola quel checksum chiamando un piccolo wrapper chiamato ZLibCRC32, che a sua volta chiama la routine crc32 propria di zlib-ng una volta che SaveAs ha finito di assemblare in memoria l'XML di un foglio di lavoro, e per molto tempo quella chiamata ha portato l'intero buffer non compresso in un'unica invocazione. È un design ragionevole per un piccolo foglio di lavoro. Diventa una chiamata molto grande nel momento in cui un foglio è del tipo trattato nella nostra guida alle prestazioni delle cartelle di lavoro di grandi dimensioni in HotXLS, dove l'XML di un singolo foglio supera abitualmente qualche centinaio di kilobyte prima ancora di essere compresso

Perché zlib-ng ha bisogno di un buffer di stack gigante per CRC32?

zlib-ng non usa un'unica implementazione CRC-32 per ogni chiamata. Sotto una soglia di dimensione percorre il buffer con lookup a tabella e trucchi di folding che non richiedono memoria extra significativa, e sopra quella soglia, circa 119 KB, precisamente 118.960 byte nella build a cui HotXLS si collega, passa a un algoritmo veloce specializzato chiamato Chorba. L'implementazione C generica di quel percorso scambia memoria per velocità: alloca un array di scratch sullo stack anziché sull'heap, dimensionato per rendere veloce il ciclo interno dell'algoritmo, non per stare comodamente dentro qualunque budget di stack il thread chiamante porti con sé. Nulla di tutto ciò è visibile dal lato del chiamante. Una funzione di checksum è normalmente una chiamata foglia, leggi qualche byte, restituisci un numero, nessuna allocazione degna di nota, e quel presupposto regge per la stragrande maggioranza delle chiamate in zlib-ng fino al momento in cui un buffer abbastanza grande da superare la soglia Chorba vi entra dentro

function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
  // One call over the whole worksheet XML buffer: fine for a small
  // sheet, but a large enough input pushes zlib-ng onto its Chorba
  // fast path and that path's stack-hungry scratch buffer
  Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;

Perché i worker thread lo vedevano e il debug interattivo mai

Scatenare questo crash richiede due condizioni contemporaneamente: una parte XML di foglio di lavoro abbastanza grande da superare la soglia Chorba di zlib-ng, e un thread che ha solo lo stack predefinito ordinario anziché qualcosa di più spazioso. I job di esportazione in produzione colpivano entrambe. Girano come job batch lato server che distribuiscono le scritture di HotXLS su un pool di worker thread, ciascuno con lo stack predefinito di 1 MB che Windows riserva a meno che un chiamante non ne richieda di più, e ciascuno che elabora cartelle di lavoro dei clienti abbastanza grandi da contare. Il debug alla scrivania non colpiva nessuna delle due condizioni in modo affidabile: i file di esempio erano di solito più piccoli della soglia, e le esecuzioni passo-passo tendevano ad avvenire sul thread principale piuttosto che dentro un worker appena generato, quindi le due condizioni che dovevano allinearsi in produzione quasi mai si allineavano alla scrivania di uno sviluppatore

Inseguire un crash che incolpava la funzione sbagliata

I report di crash che il team riusciva a ottenere puntavano a una posizione dentro la funzione deflate di zlib-ng, non a nessun codice HotXLS, e nemmeno ovviamente al codice CRC-32. Quel singolo dettaglio ha spinto il primo passaggio dell'indagine verso il percorso di compressione: dimensioni dei buffer passate a deflate, window bits, livello di compressione, tutti i soliti sospetti per un crash nativo proveniente da un codec. Nessuno di essi ha retto

Un frame superiore fuorviante

Uno stack overflow è un tipo strano di crash da simbolizzare, perché nel momento in cui viene segnalato, lo stack pointer è già passato oltre lo spazio che gli era stato riservato. Qualunque cosa abbia prodotto quel report di crash molto probabilmente ha risolto l'indirizzo che ha causato il fault sul simbolo più vicino che riusciva ancora a trovare, e il punto di ingresso esportato più vicino seduto accanto al vero colpevole si dava il caso fosse deflate. Il vero fault risiedeva nell'allocazione del buffer di scratch di Chorba dentro il percorso CRC-32, compilato nella stessa libreria, abbastanza vicino nel binario da essere scambiato per la funzione che stava effettivamente girando

Bisecare con timestamp invece che con un debugger

Un crash che abbatte l'intero processo non lascia nulla da catturare a una normale sessione di debug Delphi, quindi il team è ricaduto su checkpoint GetTickCount inseriti attorno a ogni chiamata sospetta e una bisezione manuale attraverso il percorso di salvataggio, restringendo quale operazione fosse in corso nel momento in cui il processo moriva. Accanto a ciò, una build di baseline nota-buona ha eseguito gli stessi file di produzione fianco a fianco con quella corrente, specificamente per escludere una regressione nelle modifiche di quel giro prima di guardare più a monte. Solo dopo che entrambi i controlli sono tornati puliti l'indagine si è stabilizzata su una dipendenza di terze parti che faceva qualcosa di inaspettato con un input perfettamente valido

Perché try/except non riesce a catturare uno stack overflow?

Uno stack overflow non è un'eccezione che il codice Delphi solleva mai deliberatamente, e non viene nemmeno consegnata nel modo in cui Windows consegna un access violation o una divisione per zero. Emerge come un fault hardware su guard-page, segnalato attraverso lo stesso meccanismo di structured exception handling su cui è costruito il try/except di Delphi, ma nell'esatto momento in cui scatta normalmente non resta spazio di stack per eseguire un gestore, srotolare codice di pulizia, o persino finire di segnalare pulitamente il fault. Su un worker thread che porta solo la riserva predefinita di 1 MB, con un buffer di scratch di quella dimensione che ha già consumato la maggior parte di ciò che restava, non resta nulla con cui il runtime possa lavorare

procedure TExportWorker.Execute;
var
  Workbook: TXLSXWorkbook;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    try
      BuildWorksheet(Workbook);
      Workbook.SaveAs(FTargetFile);  // crashes the process here on a
                                      // large enough sheet: try/except
                                      // never gets a chance to run
    except
      on E: Exception do
        LogError('Export failed: ' + E.Message);
    end;
  finally
    Workbook.Free;
  end;
end;

Quel blocco except sembra una rete di sicurezza, e contro la maggior parte dei fallimenti lo è, ma qui non fa nulla. Il team lo ha confermato nella pratica: try/except non ha catturato nulla, anche il blocco finally non ha mai avuto un'occasione affidabile di eseguire, e l'operatore ha visto un processo morto senza alcuna voce di log a livello applicativo, esattamente ciò che il ticket di supporto originale descriveva

La correzione: alimentare CRC32 in fette da 64 KB invece che con un'unica chiamata gigante

La correzione distribuita da HotXLS non cambia nulla in zlib-ng stesso e nulla nel livello di compressione usato per scrivere la cartella di lavoro. ZLibCRC32 ora percorre l'input in fette fisse da 64 KB, 65536 byte ciascuna, chiamando crc32 di zlib-ng una volta per fetta e trasmettendo il valore di checksum corrente da una chiamata alla successiva. CRC-32 è un algoritmo incrementale per costruzione, quindi un checksum costruito attraverso diverse fette è identico bit per bit a uno calcolato in un'unica chiamata sugli stessi byte: la correzione cambia come il lavoro viene suddiviso, non cosa calcola

function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
  // 64 KB keeps every call comfortably under the Chorba threshold
  CrcChunkSize = 65536;
var
  Cursor: PByte;
  ThisChunk: Longint;
begin
  Result := crc;
  Cursor := PByte(@buffer);
  while count > 0 do
  begin
    ThisChunk := count;
    if ThisChunk > CrcChunkSize then
      ThisChunk := CrcChunkSize;
    Result := zng_crc32(Result, Cursor, Cardinal(ThisChunk));
    Inc(Cursor, ThisChunk);
    Dec(count, ThisChunk);
  end;
end;

Nulla nella chiamata SaveAs circostante ha dovuto cambiare perché questo funzionasse, e nulla nelle voci ZIP che HotXLS scrive è cambiato: il valore CRC-32 che finisce nell'intestazione locale del file e nella directory centrale è esattamente il valore che un'unica gigantesca chiamata avrebbe prodotto, semplicemente assemblato da pezzi più piccoli. Retrocedere zlib-ng o ripiegare su un'implementazione CRC-32 più lenta e povera di allocazioni avrebbe anch'esso evitato il crash, ma a un costo reale per ogni file che non si avvicinava mai in primo luogo alla soglia, motivo per cui nessuna delle due è stata distribuita

Cosa significa questo se chiami zlib-ng dai tuoi stessi worker thread

La modalità di fallimento per stack overflow descritta qui non ha nulla a che fare specificamente con i fogli di calcolo. Qualsiasi applicazione che passa a zlib-ng un buffer grande, che sia per compressione, decompressione, o un checksum, da un thread che porta solo lo stack predefinito della piattaforma può colpire lo stesso tipo di muro, perché la libreria sceglie il proprio algoritmo in base alla dimensione dell'input e alcuni di quegli algoritmi presuppongono che ci sia stack da risparmiare. Due difese funzionano senza toccare zlib-ng stesso: alimentare buffer grandi in routine sensibili alla dimensione a blocchi fissi rimuove del tutto la condizione scatenante per qualsiasi algoritmo che sia naturalmente incrementale, e dove il chunking non è un'opzione, assegnare al thread chiamante uno stack più grande del predefinito della piattaforma è l'altra leva. Ciascuna delle due è più economica che scoprire una soglia di dimensione non documentata da un report di crash di produzione che incolpa la funzione sbagliata

Questa particolare soglia è rimasta invisibile finché una cartella di lavoro di produzione abbastanza grande non l'ha superata sul tipo sbagliato di thread, esattamente il genere di fallimento che emerge solo una volta che il codice gira contro file reali invece che piccole fixture. Il percorso CRC-32 a blocchi ora fa parte della pipeline di scrittura standard nel componente Excel HotXLS per Delphi e C++Builder, senza nulla da configurare per un chiamante e nessuna proprietà che lo attivi o disattivi