Artículo técnico

HotXLS, CRC32 de zlib-ng, y desbordamiento de pila en hilos Delphi

HotXLS puede colapsar un hilo de trabajo de Delphi sin ninguna excepción capturable cuando calcula el checksum de una parte XML de hoja de cálculo grande en una sola llamada: zlib-ng cambia a su algoritmo Chorba por encima de aproximadamente 119 KB de entrada, y la variante genérica en C de ese algoritmo asigna un arreglo de trabajo lo bastante grande como para reventar la pila de hilo predeterminada de 1 MB. Delphi nunca tiene oportunidad de reaccionar, porque un desbordamiento de pila no es el tipo de excepción para el que se construyó try/except

HotXLS es una biblioteca nativa para Delphi y C++Builder para leer y escribir libros de Excel, y el cierre inesperado se rastreó hasta su escritor de hojas de cálculo. La primera señal de problema fue un ticket de soporte: un trabajo de exportación nocturno se caía aproximadamente dos veces por semana, siempre a mitad de ejecución, sin ningún diálogo de excepción de Delphi y sin ningún error registrado, solo un proceso que desaparecía y una entrada de Windows Error Reporting que no apuntaba a nada útil. Reproducirlo en un escritorio era un asunto completamente distinto. Los libros pequeños se guardaban bien. Los libros grandes también se guardaban bien, siempre que el guardado se ejecutara en el hilo principal con un depurador ya adjunto. Se necesitó un lote real de archivos de tamaño de producción ejecutándose a través de la ruta de exportación multi-hilo real para reproducir el cierre inesperado, momento para el cual la E/S de disco, la presión de memoria, y una plantilla sospechosa ya habían sido descartadas cada una

Cómo un guardado de hoja de cálculo se convierte en una llamada CRC32 gigante

Los archivos XLSX son contenedores ZIP, y el formato ZIP requiere un checksum CRC-32 para cada entrada, registrado tanto en el encabezado de archivo local como en el directorio central. HotXLS calcula ese checksum llamando a un pequeño envoltorio llamado ZLibCRC32, que a su vez llama a la propia rutina crc32 de zlib-ng una vez que SaveAs ha terminado de ensamblar el XML de una hoja de cálculo en memoria, y durante mucho tiempo esa llamada llevó el búfer sin comprimir completo en una sola invocación. Eso es un diseño razonable para una hoja de cálculo pequeña. Se convierte en una llamada muy grande en el momento en que una hoja es del tipo cubierto en nuestra guía sobre rendimiento de libros grandes en HotXLS, donde el XML de una sola hoja rutinariamente supera unos pocos cientos de kilobytes antes de siquiera comprimirse

¿Por qué necesita zlib-ng un búfer de pila gigante para CRC32?

zlib-ng no usa una sola implementación CRC-32 para cada llamada. Por debajo de un umbral de tamaño recorre el búfer con búsquedas en tabla y trucos de plegado que no necesitan memoria extra significativa, y por encima de ese umbral, aproximadamente 119 KB, precisamente 118.960 bytes en la compilación con la que enlaza HotXLS, cambia a un algoritmo rápido especializado llamado Chorba. La implementación genérica en C de esa ruta cambia memoria por velocidad: asigna un arreglo de trabajo en la pila en lugar del heap, dimensionado para hacer rápido el bucle interno del algoritmo, no para caber cómodamente dentro de cualquiera que sea el presupuesto de pila que el hilo que llama resulte llevar. Nada de eso es visible desde el lado de quien llama. Una función de checksum normalmente es una llamada hoja, leer algunos bytes, devolver un número, ninguna asignación que valga la pena mencionar, y esa suposición se mantiene para la abrumadora mayoría de las llamadas a zlib-ng hasta que un búfer lo bastante grande como para cruzar el umbral de Chorba entra en escena

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 qué los hilos de trabajo lo vieron y la depuración interactiva nunca lo hizo

Disparar este cierre inesperado requiere dos condiciones al mismo tiempo: una parte XML de hoja de cálculo lo bastante grande como para cruzar el umbral Chorba de zlib-ng, y un hilo que solo tiene la pila predeterminada ordinaria en lugar de algo más amplio. Los trabajos de exportación de producción cumplían ambas. Se ejecutan como trabajos por lotes del lado del servidor que distribuyen las escrituras de HotXLS entre un pool de hilos de trabajo, cada uno llevando la pila predeterminada de 1 MB que Windows reserva a menos que quien llama pida más, y cada uno procesando libros de cliente lo bastante grandes como para importar. La depuración de escritorio no cumplía ninguna de las dos condiciones de forma confiable: los archivos de muestra normalmente eran más pequeños que el umbral, y las ejecuciones paso a paso tendían a ocurrir en el hilo principal en lugar de dentro de un hilo de trabajo recién generado, así que las dos condiciones que tenían que coincidir en producción casi nunca coincidían en el escritorio de un desarrollador

Persiguiendo un cierre inesperado que culpaba a la función equivocada

Los reportes de cierre inesperado a los que el equipo pudo echar mano apuntaban a una ubicación dentro de la función deflate de zlib-ng, no a ningún código de HotXLS, y tampoco obviamente al código CRC-32. Ese único detalle envió la primera pasada de la investigación hacia la ruta de compresión: tamaños de búfer pasados a deflate, bits de ventana, nivel de compresión, todos los sospechosos habituales para un cierre inesperado nativo que viene de un códec. Ninguno se sostuvo

Un marco superior engañoso

Un desbordamiento de pila es un tipo extraño de cierre inesperado para simbolizar, porque para cuando se reporta, el puntero de pila ya ha rebasado el espacio que se había reservado para él. Lo que sea que produjo ese reporte de cierre inesperado probablemente resolvió la dirección que falló al símbolo más cercano que todavía pudo encontrar, y el punto de entrada exportado más cercano sentado junto al verdadero culpable resultó ser deflate. La falla real estaba en la asignación del búfer de trabajo de Chorba dentro de la ruta CRC-32, compilada en la misma biblioteca, lo bastante cerca en el binario como para confundirse con la función que realmente se estaba ejecutando

Bisección con marcas de tiempo en lugar de un depurador

Un cierre inesperado que derriba todo el proceso no deja nada que una sesión de depurador Delphi normal pueda atrapar, así que el equipo recurrió a puntos de control GetTickCount colocados alrededor de cada llamada sospechosa y una bisección manual a través de la ruta de guardado, acotando qué operación estaba en curso en el momento en que el proceso murió. Junto con eso, una compilación de referencia conocida como buena ejecutó los mismos archivos de producción en paralelo con la actual, específicamente para descartar una regresión en los propios cambios de esa ronda antes de mirar más atrás aguas arriba. Solo después de que ambas comprobaciones salieran limpias, la investigación se asentó en una dependencia de terceros haciendo algo inesperado con una entrada perfectamente válida

¿Por qué try/except no logra capturar un desbordamiento de pila?

Un desbordamiento de pila no es una excepción que el código Delphi lance jamás a propósito, y tampoco se entrega de la manera en que Windows entrega una violación de acceso o una división por cero. Aparece como una falla de página de guarda de hardware, reportada a través del mismo mecanismo de manejo de excepciones estructurado sobre el que está construido el try/except de Delphi, pero en el momento exacto en que se dispara normalmente no queda espacio de pila para ejecutar un manejador, desenrollar código de limpieza, o siquiera terminar de reportar la falla de forma limpia. En un hilo de trabajo que solo lleva la reserva predeterminada de 1 MB, con un búfer de trabajo de ese tamaño ya habiendo consumido la mayor parte de lo que quedaba, no queda nada con lo que el runtime pueda trabajar

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;

Ese bloque except se ve como una red de seguridad, y contra la mayoría de los fallos lo es, pero aquí no hace nada. El equipo lo confirmó en la práctica: try/except no capturó nada, el bloque finally tampoco tuvo nunca una oportunidad confiable de ejecutarse, y el operador vio un proceso muerto sin ninguna entrada de log a nivel de aplicación en absoluto, exactamente lo que describía el ticket de soporte original

La solución: entregar CRC32 en fragmentos de 64 KB en lugar de una llamada gigante

La solución que HotXLS incorporó no cambia nada sobre el propio zlib-ng ni sobre el nivel de compresión usado para escribir el libro. ZLibCRC32 ahora recorre la entrada en fragmentos fijos de 64 KB, 65536 bytes cada uno, llamando al crc32 de zlib-ng una vez por fragmento y encadenando el valor de checksum en curso de una llamada a la siguiente. CRC-32 es un algoritmo incremental por construcción, así que un checksum construido a lo largo de varios fragmentos es idéntico bit por bit a uno calculado en una sola llamada sobre los mismos bytes: la solución cambia cómo se divide el trabajo, no lo que 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 la llamada SaveAs circundante tuvo que cambiar para que esto funcione, y nada sobre las entradas ZIP que escribe HotXLS cambió tampoco: el valor CRC-32 que termina en el encabezado de archivo local y el directorio central es exactamente el valor que una sola llamada gigante habría producido, solo que ensamblado a partir de piezas más pequeñas. Bajar de versión zlib-ng o recurrir a una implementación CRC-32 más lenta y ligera en asignaciones también habría evitado el cierre inesperado, pero a un costo real para cada archivo que nunca se acercó al umbral en primer lugar, que es por qué ninguna de las dos se incorporó

Qué significa esto si usted llama a zlib-ng desde sus propios hilos de trabajo

El modo de fallo de desbordamiento de pila descrito aquí no tiene nada que ver específicamente con hojas de cálculo. Cualquier aplicación que le entregue a zlib-ng un búfer grande, ya sea para compresión, descompresión, o un checksum, desde un hilo que solo lleva la pila predeterminada de la plataforma puede toparse con el mismo tipo de muro, porque la biblioteca elige su algoritmo según el tamaño de entrada y algunos de esos algoritmos asumen que hay pila de sobra. Dos defensas funcionan sin tocar zlib-ng en sí: entregar búferes grandes a rutinas sensibles al tamaño en fragmentos fijos elimina la condición de disparo por completo para cualquier algoritmo que sea naturalmente incremental, y donde fragmentar no es una opción, darle al hilo que llama una pila más grande que la predeterminada de la plataforma es la otra palanca. Cualquiera de las dos es más barata que descubrir un umbral de tamaño no documentado a partir de un reporte de cierre inesperado de producción que culpa a la función equivocada

Este umbral en particular permaneció invisible hasta que un libro de producción lo bastante grande lo cruzó en el tipo equivocado de hilo, que es exactamente el tipo de fallo que solo aparece una vez que el código se ejecuta contra archivos reales en lugar de fixtures pequeños. La ruta CRC-32 fragmentada ahora se incluye como parte del pipeline de escritura estándar en el componente Excel HotXLS para Delphi y C++Builder, sin nada que quien llama tenga que configurar y sin ninguna propiedad que lo active o desactive