Artigo Técnico

HotXLS, CRC32 do zlib-ng e Estouro de Pilha em Threads Delphi

O HotXLS pode derrubar uma worker thread Delphi sem nenhuma exceção capturável quando faz o checksum de uma parte XML de planilha grande em uma única chamada: o zlib-ng troca para seu algoritmo Chorba acima de aproximadamente 119 KB de entrada, e a variante C genérica desse algoritmo aloca um array de rascunho grande o bastante para estourar a pilha padrão de 1 MB da thread. O Delphi nunca tem chance de reagir, porque um estouro de pilha não é o tipo de exceção que o try/except foi construído para capturar

O HotXLS é uma biblioteca Delphi e C++Builder nativa para ler e escrever pastas de trabalho Excel, e a quebra foi rastreada até seu escritor de planilha. O primeiro sinal do problema foi um chamado de suporte: um job de exportação noturno quebrava aproximadamente duas vezes por semana, sempre no meio da execução, sem diálogo de exceção Delphi e sem erro registrado, apenas um processo que desaparecia e uma entrada de Relatório de Erros do Windows que não apontava para nada útil. Reproduzir isso na mesa era outra questão inteiramente diferente. Pastas de trabalho pequenas salvavam bem. Pastas de trabalho grandes também salvavam bem, desde que o salvamento rodasse na thread principal com um depurador já anexado. Foi preciso um lote real de arquivos de tamanho de produção rodando pelo caminho de exportação multi-thread real para trazer a quebra para casa, momento em que I/O de disco, pressão de memória e um modelo suspeito já haviam sido descartados

Como um salvamento de planilha vira uma única chamada gigante de CRC32

Arquivos XLSX são contêineres ZIP, e o formato ZIP exige um checksum CRC-32 para cada entrada, registrado tanto no cabeçalho de arquivo local quanto no diretório central. O HotXLS calcula esse checksum chamando um pequeno wrapper chamado ZLibCRC32, que por sua vez chama a própria rotina crc32 do zlib-ng assim que SaveAs termina de montar o XML de uma planilha em memória, e por muito tempo essa chamada carregou o buffer descomprimido inteiro em uma única invocação. Esse é um design razoável para uma planilha pequena. Vira uma chamada muito grande no instante em que uma planilha é do tipo coberto em nosso guia de desempenho de pastas de trabalho grandes no HotXLS, onde o XML de uma única planilha rotineiramente ultrapassa algumas centenas de quilobytes antes mesmo de ser comprimido

Por que o zlib-ng precisa de um buffer de pilha gigante para CRC32?

O zlib-ng não usa uma única implementação de CRC-32 para cada chamada. Abaixo de um limiar de tamanho, ele percorre o buffer com buscas em tabela e truques de dobramento que não precisam de memória extra significativa, e acima desse limiar, aproximadamente 119 KB, precisamente 118.960 bytes na build com a qual o HotXLS linka, ele troca para um algoritmo rápido especializado chamado Chorba. A implementação C genérica desse caminho troca memória por velocidade: aloca um array de rascunho na pilha, em vez do heap, dimensionado para tornar o loop interno do algoritmo rápido, não para caber confortavelmente dentro de qualquer orçamento de pilha que a thread chamadora por acaso carregue. Nada disso é visível do lado de quem chama. Uma função de checksum normalmente é uma chamada folha, lê alguns bytes, retorna um número, sem alocação digna de discussão, e essa suposição vale para a esmagadora maioria das chamadas ao zlib-ng até que um buffer grande o bastante para cruzar o limiar do Chorba apareça

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;

Por que worker threads viram isso e a depuração interativa nunca viu

Disparar essa quebra exige duas condições ao mesmo tempo: uma parte XML de planilha grande o bastante para cruzar o limiar Chorba do zlib-ng, e uma thread que só tem a pilha padrão comum, em vez de algo mais espaçoso. Jobs de exportação de produção atingem ambas. Eles rodam como jobs em lote do lado do servidor que espalham escritas do HotXLS por um pool de worker threads, cada uma carregando a pilha padrão de 1 MB que o Windows reserva a menos que quem chama peça mais, e cada uma processando pastas de trabalho de cliente grandes o bastante para importar. Depuração de mesa não atingia nenhuma das duas condições de forma confiável: arquivos de amostra geralmente eram menores que o limiar, e execuções passo a passo tendiam a acontecer na thread principal, em vez de dentro de uma worker recém-gerada, de modo que as duas condições que precisavam se alinhar em produção quase nunca se alinhavam na mesa de um desenvolvedor

Perseguindo uma quebra que culpava a função errada

Os relatórios de quebra que a equipe conseguiu obter apontavam para uma localização dentro da função deflate do zlib-ng, não para nenhum código do HotXLS, e não obviamente para o código de CRC-32 também. Esse único detalhe direcionou a primeira passada da investigação para o caminho de compressão: tamanhos de buffer passados para deflate, bits de janela, nível de compressão, todos os suspeitos usuais para uma quebra nativa vinda de um codec. Nenhum deles se sustentou

Um quadro superior enganoso

Um estouro de pilha é um tipo estranho de quebra para simbolizar, porque, no momento em que é reportado, o ponteiro de pilha já ultrapassou o espaço que estava reservado para ele. O que quer que produziu esse relatório de quebra provavelmente resolveu o endereço causador da falha para o símbolo mais próximo que ainda conseguiu encontrar, e o ponto de entrada exportado mais próximo, sentado ao lado do verdadeiro culpado, por acaso era deflate. A falha real estava na alocação do buffer de rascunho do Chorba, dentro do caminho de CRC-32, compilada na mesma biblioteca, próxima o bastante no binário para ser confundida com a função que estava de fato rodando

Bissectando com timestamps em vez de um depurador

Uma quebra que derruba o processo inteiro não deixa nada para uma sessão normal de depurador Delphi capturar, então a equipe recorreu a checkpoints de GetTickCount colocados ao redor de cada chamada suspeita e uma bissecção manual pelo caminho de salvamento, restringindo qual operação estava em andamento no momento em que o processo morreu. Ao lado disso, uma build de referência conhecida como boa rodou os mesmos arquivos de produção lado a lado com a atual, especificamente para descartar uma regressão nas próprias mudanças daquela rodada antes de olhar mais adiante rio acima. Só depois de ambas as verificações voltarem limpas é que a investigação se estabeleceu em uma dependência de terceiros fazendo algo inesperado com uma entrada perfeitamente válida

Por que try/except falha em capturar um estouro de pilha?

Um estouro de pilha não é uma exceção que o código Delphi levanta de propósito, e também não é entregue da forma como o Windows entrega uma violação de acesso ou uma divisão por zero. Ele surge como uma falha de página de guarda de hardware, reportada por meio do mesmo mecanismo de tratamento de exceção estruturada sobre o qual o try/except do Delphi é construído, mas no exato momento em que dispara normalmente não há espaço de pilha restante para rodar um handler, desenrolar código de limpeza, ou mesmo terminar de reportar a falha de forma limpa. Em uma worker thread carregando apenas a reserva padrão de 1 MB, com um buffer de rascunho daquele tamanho já tendo consumido a maior parte do que restava, não sobra nada com que o runtime trabalhar

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;

Esse bloco except parece uma rede de segurança, e contra a maioria das falhas é uma, mas não faz nada aqui. A equipe confirmou isso na prática: try/except não capturou nada, o bloco finally também nunca teve uma chance confiável de rodar, e o operador viu um processo morto sem nenhuma entrada de log em nível de aplicação, exatamente o que o chamado de suporte original descreveu

A correção: alimentar CRC32 em fatias de 64 KB em vez de uma chamada gigante

A correção que o HotXLS lançou não muda nada sobre o próprio zlib-ng e nada sobre o nível de compressão usado para escrever a pasta de trabalho. ZLibCRC32 agora percorre a entrada em fatias fixas de 64 KB, 65536 bytes cada, chamando o crc32 do zlib-ng uma vez por fatia e encadeando o valor de checksum corrente de uma chamada para a próxima. CRC-32 é um algoritmo incremental por construção, de modo que um checksum construído ao longo de várias fatias é idêntico bit a bit a um calculado em uma única chamada sobre os mesmos bytes: a correção muda como o trabalho é dividido, não o que ele calcula

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;

Nada sobre a chamada SaveAs ao redor precisou mudar para isso funcionar, e nada sobre as entradas ZIP que o HotXLS escreve mudou também: o valor CRC-32 que acaba no cabeçalho de arquivo local e no diretório central é exatamente o valor que uma única chamada gigante teria produzido, apenas montado a partir de pedaços menores. Rebaixar a versão do zlib-ng ou recair para uma implementação de CRC-32 mais lenta e leve em alocação também teria evitado a quebra, mas a um custo real para todo arquivo que nunca chegasse perto do limiar, para começo de conversa, motivo pelo qual nenhuma das duas opções foi lançada

O que isso significa se você chama o zlib-ng a partir de suas próprias worker threads

O modo de falha de estouro de pilha descrito aqui não tem nada a ver com planilhas especificamente. Qualquer aplicação que entrega ao zlib-ng um buffer grande, seja para compressão, descompressão, ou um checksum, a partir de uma thread que só carrega a pilha padrão da plataforma pode atingir o mesmo tipo de parede, porque a biblioteca escolhe seu algoritmo pelo tamanho da entrada, e alguns desses algoritmos assumem que há pilha de sobra. Duas defesas funcionam sem tocar no próprio zlib-ng: alimentar buffers grandes em rotinas sensíveis a tamanho em blocos fixos remove a condição de disparo por completo para qualquer algoritmo que seja naturalmente incremental, e onde dividir em blocos não é uma opção, dar à thread chamadora uma pilha maior que o padrão da plataforma é a outra alavanca. Qualquer uma das duas é mais barata do que descobrir sobre um limiar de tamanho não documentado a partir de um relatório de quebra de produção que culpa a função errada

Esse limiar em particular permaneceu invisível até uma pasta de trabalho de produção grande o bastante cruzá-lo no tipo errado de thread, que é exatamente o tipo de falha que só aparece uma vez que o código roda contra arquivos reais, em vez de pequenos fixtures. O caminho de CRC-32 em blocos agora vem como parte do pipeline de escrita padrão no Componente Excel HotXLS para Delphi e C++Builder, sem nada para quem chama configurar e sem propriedade que a ative ou desative