O HotXLS pode provocar o crash de uma thread de trabalho Delphi sem qualquer exceção detetável quando calcula a soma de verificação de uma parte XML de folha de cálculo de grande dimensão numa única chamada: o zlib-ng muda para o seu algoritmo Chorba acima de aproximadamente 119 KB de entrada, e a variante em C genérico desse algoritmo aloca um array de trabalho suficientemente grande para ultrapassar a pilha predefinida de 1 MB da thread. O Delphi nunca chega a ter oportunidade de reagir, porque um estouro de pilha não é o tipo de exceção que o try/except foi concebido para apanhar
O HotXLS é uma biblioteca nativa para Delphi e C++Builder destinada à leitura e escrita de pastas de trabalho do Excel, e o crash acabou por ser rastreado até ao seu escritor de folhas de cálculo. O primeiro sinal do problema foi um pedido de suporte: uma tarefa de exportação noturna tinha crashes cerca de duas vezes por semana, sempre a meio da execução, sem qualquer caixa de diálogo de exceção do Delphi e sem qualquer erro registado, apenas um processo que desaparecia e uma entrada do Relatório de Erros do Windows que não apontava para nada de útil. Reproduzir isto ao computador foi outra questão inteiramente diferente. Pastas de trabalho pequenas guardavam-se sem problemas. Pastas de trabalho grandes também se guardavam sem problemas, desde que a gravação corresse na thread principal com um depurador já anexado. Foi preciso um lote real de ficheiros com dimensão de produção a percorrer o verdadeiro caminho de exportação multithread para trazer o crash à tona, altura em que a E/S de disco, a pressão de memória e um modelo suspeito já tinham sido individualmente excluídos
Como é que gravar uma folha de cálculo se transforma numa única chamada gigante de CRC32?
Os ficheiros XLSX são contentores ZIP, e o formato ZIP exige uma soma de verificação CRC-32 para cada entrada, registada tanto no cabeçalho de ficheiro local como no diretório central. O HotXLS calcula essa soma de verificação chamando um pequeno wrapper designado ZLibCRC32, que por sua vez invoca a rotina crc32 do próprio zlib-ng assim que o SaveAs termina de montar o XML de uma folha de cálculo em memória, e durante muito tempo essa chamada transportou o buffer inteiro não comprimido numa única invocação. Este é um desenho razoável para uma folha de cálculo pequena. Torna-se numa chamada muito grande a partir do momento em que uma folha é do tipo abordado no nosso guia sobre desempenho em pastas de trabalho grandes no HotXLS, onde o XML de uma única folha ultrapassa rotineiramente algumas centenas de kilobytes antes de sequer ser comprimido
Porque é que o zlib-ng precisa de um buffer de pilha gigante para o CRC32?
O zlib-ng não usa uma única implementação de CRC-32 para todas as chamadas. Abaixo de um determinado limiar de tamanho, percorre o buffer com pesquisas em tabela e técnicas de dobragem que não exigem memória extra significativa, e acima desse limiar, aproximadamente 119 KB, precisamente 118 960 bytes na compilação com que o HotXLS faz link, muda para um algoritmo rápido especializado chamado Chorba. A implementação em C genérico desse caminho troca memória por velocidade: aloca um array de trabalho na pilha em vez de no heap, dimensionado para tornar o ciclo interno do algoritmo rápido, não para caber confortavelmente dentro do orçamento de pilha que a thread chamadora tenha disponível. Nada disto é visível do lado do chamador. Uma função de soma de verificação é normalmente uma chamada terminal, lê alguns bytes, devolve um número, sem alocação que valha a pena mencionar, e essa suposição mantém-se válida para a esmagadora maioria das chamadas ao zlib-ng, até que um buffer suficientemente grande para ultrapassar o limiar do Chorba apareça numa delas
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;
Porque é que as threads de trabalho se deparavam com o problema e a depuração interativa nunca o via
Desencadear este crash exige duas condições ao mesmo tempo: uma parte de XML de folha de cálculo suficientemente grande para ultrapassar o limiar Chorba do zlib-ng, e uma thread que dispõe apenas da pilha predefinida comum em vez de algo mais generoso. As tarefas de exportação em produção reuniam ambas as condições. Corriam como tarefas em lote do lado do servidor que distribuem as escritas do HotXLS por um conjunto de threads de trabalho, cada uma com a pilha predefinida de 1 MB que o Windows reserva a menos que o chamador peça mais, e cada uma a processar pastas de trabalho de clientes suficientemente grandes para importar. A depuração ao computador nunca reunia, de forma fiável, nenhuma das duas condições: os ficheiros de amostra eram normalmente mais pequenos do que o limiar, e as execuções passo a passo tendiam a acontecer na thread principal em vez de dentro de uma thread de trabalho recém-criada, pelo que as duas condições que tinham de coincidir em produção quase nunca coincidiam na secretária de um programador
Perseguir um crash que apontava para a função errada
Os relatórios de crash a que a equipa conseguiu ter acesso apontavam para uma localização dentro da função deflate do zlib-ng, não para nenhum código do HotXLS, nem, aparentemente, para o código do CRC-32. Esse único pormenor levou a primeira fase da investigação na direção do caminho de compressão: tamanhos de buffer passados ao deflate, bits de janela, nível de compressão, todos os habituais suspeitos de um crash nativo vindo de um codec. Nenhum deles se confirmou
Um frame de topo enganador
Um estouro de pilha é um tipo estranho de crash para simbolizar, porque quando é reportado, o ponteiro de pilha já ultrapassou o espaço que lhe estava reservado. Fosse o que fosse que produziu esse relatório de crash, muito 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 do verdadeiro culpado calhou ser o deflate. A falha real estava na alocação do buffer de trabalho do Chorba dentro do caminho do CRC-32, compilada na mesma biblioteca, suficientemente próxima no binário para ser confundida com a função que estava efetivamente a correr
Bissecção com marcas temporais em vez de um depurador
Um crash que derruba o processo inteiro não deixa nada para uma sessão normal de depurador Delphi apanhar, pelo que a equipa recorreu a pontos de verificação com GetTickCount colocados à volta de cada chamada suspeita e a uma bissecção manual ao longo do caminho de gravação, restringindo qual operação estava em curso no momento em que o processo morria. Em paralelo, uma compilação de referência conhecida como estável correu os mesmos ficheiros de produção lado a lado com a versão atual, especificamente para excluir uma regressão introduzida pelas alterações dessa ronda antes de procurar mais acima na cadeia. Só depois de ambas as verificações darem resultado limpo é que a investigação se fixou numa dependência de terceiros a fazer algo inesperado com uma entrada perfeitamente válida
Porque é que o try/except falha ao tentar apanhar um estouro de pilha?
Um estouro de pilha não é uma exceção que o código Delphi alguma vez levante de propósito, e também não é entregue da mesma forma que o Windows entrega uma violação de acesso ou uma divisão por zero. Manifesta-se como uma falha de página de proteção a nível de hardware, reportada através do mesmo mecanismo de tratamento estruturado de exceções em que o try/except do Delphi assenta, mas no momento exato em que dispara normalmente já não resta espaço de pilha para correr um handler, desenrolar código de limpeza, ou sequer terminar de reportar a falha de forma limpa. Numa thread de trabalho com apenas a reserva predefinida de 1 MB, com um buffer de trabalho desse tamanho já a ter consumido a maior parte do que restava, não sobra nada com que o runtime possa 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;
Aquele bloco except parece uma rede de segurança, e contra a maioria das falhas até é uma, mas aqui não faz nada. A equipa confirmou isso mesmo na prática: o try/except não apanhou nada, o bloco finally também nunca teve uma oportunidade fiável de correr, e o operador via um processo morto sem qualquer entrada de registo ao nível da aplicação, exatamente o que o pedido de suporte original descrevia
A correção: fornecer o CRC32 em fatias de 64 KB em vez de uma única chamada gigante
A correção que o HotXLS distribuiu não altera nada no próprio zlib-ng nem no nível de compressão usado para gravar a pasta de trabalho. O 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 soma de verificação corrente de uma chamada para a seguinte. O CRC-32 é, por construção, um algoritmo incremental, pelo que uma soma de verificação construída ao longo de várias fatias é, bit a bit, idêntica a uma calculada numa única chamada sobre os mesmos bytes: a correção altera a forma como o trabalho é dividido, não o que é calculado
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 na chamada SaveAs envolvente teve de mudar para que isto funcionasse, e nada nas entradas ZIP que o HotXLS escreve mudou também: o valor CRC-32 que acaba por ficar no cabeçalho de ficheiro local e no diretório central é exatamente o valor que uma única chamada gigante teria produzido, apenas montado a partir de peças mais pequenas. Fazer downgrade do zlib-ng ou recorrer a uma implementação de CRC-32 mais lenta e com menos alocação também teria evitado o crash, mas a um custo real para todos os ficheiros que nunca sequer se aproximavam do limiar em primeiro lugar, razão pela qual nenhuma das duas opções foi lançada
O que isto significa se chamar o zlib-ng a partir das suas próprias threads de trabalho
O modo de falha por estouro de pilha aqui descrito não tem nada de específico com folhas de cálculo. Qualquer aplicação que entregue ao zlib-ng um buffer de grande dimensão, seja para compressão, descompressão, ou uma soma de verificação, a partir de uma thread que dispõe apenas da pilha predefinida da plataforma, pode esbarrar no mesmo tipo de obstáculo, porque a biblioteca escolhe o seu algoritmo em função do tamanho da entrada, e alguns desses algoritmos partem do princípio de que há pilha de sobra. Duas defesas funcionam sem tocar no próprio zlib-ng: fornecer buffers grandes a rotinas sensíveis ao tamanho em blocos fixos elimina por completo a condição de disparo para qualquer algoritmo que seja naturalmente incremental, e, quando dividir em blocos não é uma opção, dar à thread chamadora uma pilha maior do que a predefinida da plataforma é a outra alavanca disponível. Qualquer uma das duas opções é mais barata do que descobrir um limiar de tamanho não documentado através de um relatório de crash de produção que aponta para a função errada
Este limiar em particular manteve-se invisível até uma pasta de trabalho de produção suficientemente grande o ultrapassar no tipo errado de thread, exatamente o género de falha que só se manifesta quando o código corre contra ficheiros reais em vez de pequenos casos de teste. O caminho de CRC-32 em blocos faz agora parte, por norma, do pipeline padrão de escrita no Componente Excel HotXLS para Delphi e C++Builder, sem nada para o chamador configurar e sem qualquer propriedade que o ative ou desative