Technical Article

HotXLS, zlib-ng CRC32, and Stack Overflow in Delphi Threads

HotXLS can crash a Delphi worker thread with no catchable exception when it checksums a large worksheet XML part in one call: zlib-ng switches to its Chorba algorithm above roughly 119 KB of input, and the generic-C variant of that algorithm allocates a scratch array large enough to blow through the default 1 MB thread stack. Delphi never gets a chance to react, because a stack overflow is not the kind of exception that try/except was built to catch

HotXLS is a native Delphi and C++Builder library for reading and writing Excel workbooks, and the crash traced back to its worksheet writer. The first sign of trouble was a support ticket: a nightly export job crashed roughly twice a week, always mid-run, with no Delphi exception dialog and no logged error, just a process that vanished and a Windows Error Reporting entry that pointed nowhere useful. Reproducing it at a desk was another matter entirely. Small workbooks saved fine. Large workbooks saved fine too, as long as the save ran on the main thread with a debugger already attached. It took an actual batch of production-sized files running through the real multi-threaded export path to bring the crash home, by which point disk I/O, memory pressure, and a suspect template had each already been ruled out

How a worksheet save turns into one giant CRC32 call

XLSX files are ZIP containers, and the ZIP format requires a CRC-32 checksum for every entry, recorded in both the local file header and the central directory. HotXLS computes that checksum by calling a small wrapper named ZLibCRC32, which in turn calls zlib-ng's own crc32 routine once SaveAs has finished assembling a worksheet's XML in memory, and for a long time that call carried the entire uncompressed buffer in a single invocation. That is a reasonable design for a small worksheet. It becomes one very large call the moment a sheet is the kind covered in our guide to large-workbook performance in HotXLS, where a single sheet's XML routinely runs past a few hundred kilobytes before it is ever compressed

Why does zlib-ng need a giant stack buffer for CRC32?

zlib-ng does not use one CRC-32 implementation for every call. Below a size threshold it walks the buffer with table lookups and folding tricks that need no meaningful extra memory, and above that threshold, roughly 119 KB, precisely 118,960 bytes in the build HotXLS links against, it switches to a specialized fast algorithm called Chorba. The generic-C implementation of that path trades memory for speed: it allocates a scratch array on the stack rather than the heap, sized to make the algorithm's inner loop fast, not to fit comfortably inside whatever stack budget the calling thread happens to carry. None of that is visible from the caller's side. A checksum function is normally a leaf call, read some bytes, return a number, no allocation worth discussing, and that assumption holds for the overwhelming majority of calls into zlib-ng right up until a buffer large enough to cross the Chorba threshold walks into one

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;

Why worker threads saw it and interactive debugging never did

Triggering this crash takes two conditions at the same time: a worksheet XML part large enough to cross zlib-ng's Chorba threshold, and a thread that has only the ordinary default stack rather than something roomier. Production export jobs hit both. They run as server-side batch jobs that spread HotXLS writes across a pool of worker threads, each carrying the default 1 MB stack that Windows reserves unless a caller asks for more, and each one processing customer workbooks large enough to matter. Desk debugging hit neither condition reliably: sample files were usually smaller than the threshold, and single-step runs tended to happen on the main thread rather than inside a freshly spawned worker, so the two conditions that had to line up in production almost never lined up at a developer's desk

Chasing a crash that blamed the wrong function

The crash reports the team could get their hands on pointed at a location inside zlib-ng's deflate function, not at any HotXLS code, and not obviously at the CRC-32 code either. That single detail sent the first pass of the investigation toward the compression path: buffer sizes passed to deflate, window bits, compression level, all the usual suspects for a native crash coming out of a codec. None of them held up

A misleading top frame

A stack overflow is a strange kind of crash to symbolize, because by the time it gets reported, the stack pointer has already run past the space that was reserved for it. Whatever produced that crash report most likely resolved the faulting address to the nearest symbol it could still find, and the nearest exported entry point sitting next to the real culprit happened to be deflate. The actual fault sat in the Chorba scratch-buffer allocation inside the CRC-32 path, compiled into the same library, close enough in the binary to be mistaken for the function that was actually running

Bisecting with timestamps instead of a debugger

A crash that takes the whole process down leaves nothing for a normal Delphi debugger session to catch, so the team fell back on GetTickCount checkpoints dropped around every suspect call and a manual bisection across the save path, narrowing down which operation was in flight at the moment the process died. Alongside that, a known-good baseline build ran the same production files side by side with the current one, specifically to rule out a regression in that round's own changes before looking any further upstream. Only after both checks came back clean did the investigation settle on a third-party dependency doing something unexpected with a perfectly valid input

Why does try/except fail to catch a stack overflow?

A stack overflow is not an exception that Delphi code ever raises on purpose, and it is not delivered the way Windows delivers an access violation or a divide-by-zero either. It surfaces as a hardware guard-page fault, reported through the same structured exception handling mechanism that Delphi's try/except is built on, but at the exact moment it fires there is normally no stack space left to run a handler, unwind cleanup code, or even finish reporting the fault cleanly. On a worker thread carrying only the default 1 MB reservation, with a scratch buffer that size already having consumed most of what was left, there is nothing left for the runtime to work with

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;

That except block looks like a safety net, and against most failures it is one, but it does nothing here. The team confirmed as much in practice: try/except caught nothing, the finally block never got a reliable chance to run either, and the operator saw a dead process with no application-level log entry at all, exactly what the original support ticket described

The fix: feed CRC32 in 64 KB slices instead of one giant call

The fix HotXLS shipped changes nothing about zlib-ng itself and nothing about the compression level used to write the workbook. ZLibCRC32 now walks the input in fixed 64 KB slices, 65536 bytes each, calling zlib-ng's crc32 once per slice and threading the running checksum value from one call into the next. CRC-32 is an incremental algorithm by construction, so a checksum built up over several slices is bit-for-bit identical to one computed in a single call over the same bytes: the fix changes how the work is split, not what it computes

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;

Nothing about the surrounding SaveAs call had to change for this to work, and nothing about the ZIP entries HotXLS writes changed either: the CRC-32 value that ends up in the local file header and the central directory is exactly the value a single giant call would have produced, just assembled from smaller pieces. Downgrading zlib-ng or falling back to a slower, allocation-light CRC-32 implementation would also have avoided the crash, but at a real cost to every file that never came close to the threshold in the first place, which is why neither one shipped

What this means if you call zlib-ng from your own worker threads

The stack overflow failure mode described here has nothing to do with spreadsheets specifically. Any application that hands zlib-ng a large buffer, whether for compression, decompression, or a checksum, from a thread that only carries the platform default stack can hit the same kind of wall, because the library picks its algorithm by input size and some of those algorithms assume there is stack to spare. Two defenses work without touching zlib-ng itself: feeding large buffers into size-sensitive routines in fixed chunks removes the trigger condition outright for any algorithm that is naturally incremental, and where chunking is not an option, giving the calling thread a stack larger than the platform default is the other lever. Either one is cheaper than finding out about an undocumented size threshold from a production crash report that blames the wrong function

This particular threshold stayed invisible until a large enough production workbook crossed it on the wrong kind of thread, which is exactly the sort of failure that only shows up once code runs against real files instead of small fixtures. The chunked CRC-32 path now ships as part of the standard write pipeline in the HotXLS Excel Component for Delphi and C++Builder, with nothing for a caller to configure and no property that turns it on or off