Artículo técnico

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

HotXLS podía bloquear un hilo de trabajo Delphi sin ninguna excepción capturable al calcular 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 en C genérico de ese algoritmo reserva un array de trabajo lo bastante grande como para reventar la pila de hilo de 1 MB por defecto. Delphi nunca llega a tener ocasión 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 fallo se rastreó hasta su escritor de hojas de cálculo. La primera señal del problema fue un ticket de soporte: un trabajo de exportación nocturno fallaba 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 llevaba a ningún sitio útil. Reproducirlo en un escritorio era otro 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 conectado. Hizo falta un lote real de archivos de tamaño de producción ejecutándose a través de la vía de exportación multihilo real para traer el fallo a casa, momento en el que ya se habían descartado tanto la E/S de disco como la presión de memoria y una plantilla sospechosa

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

Los archivos XLSX son contenedores ZIP, y el formato ZIP exige un checksum CRC-32 para cada entrada, registrado tanto en la cabecera 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 en cuanto SaveAs termina de ensamblar el XML de una hoja de cálculo en memoria, y durante mucho tiempo esa llamada llevaba todo el búfer sin comprimir en una única invocación. 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 el rendimiento de libros grandes en HotXLS, donde el XML de una única hoja rutinariamente supera unos pocos cientos de kilobytes antes incluso de comprimirse

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

zlib-ng no usa una única implementación de 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 adicional significativa, y por encima de ese umbral, aproximadamente 119 KB, exactamente 118.960 bytes en la compilación con la que enlaza HotXLS, cambia a un algoritmo rápido especializado llamado Chorba. La implementación en C genérico de esa vía cambia memoria por velocidad: reserva un array de trabajo en la pila en lugar de en el montón, dimensionado para hacer rápido el bucle interno del algoritmo, no para encajar cómodamente dentro de cualquiera que sea el presupuesto de pila que lleve el hilo que llama. Nada de esto 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, sin reserva de memoria que merezca la pena mencionar, y esa suposición se sostiene para la inmensa 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é lo veían los hilos de trabajo y la depuración interactiva nunca

Disparar este fallo requiere dos condiciones a la vez: 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 tenga la pila por defecto 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 reparten las escrituras de HotXLS entre un grupo de hilos de trabajo, cada uno con la pila por defecto de 1 MB que Windows reserva a menos que quien llame 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 fiable: los archivos de muestra solían ser 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 fallo que culpaba a la función equivocada

Los informes de fallo a los que el equipo pudo echar mano señalaban 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 vía de compresión: tamaños de búfer pasados a deflate, bits de ventana, nivel de compresión, todos los sospechosos habituales para un fallo nativo que sale de un códec. Ninguno de ellos se sostuvo

Un fotograma superior engañoso

Un desbordamiento de pila es un tipo extraño de fallo de simbolizar, porque para cuando se reporta, el puntero de pila ya ha superado el espacio que se le había reservado. Fuera lo que fuera lo que produjo ese informe de fallo, lo más probable es que resolviera la dirección que falló al símbolo más cercano que aún pudo encontrar, y el punto de entrada exportado más cercano situado junto al verdadero culpable resultó ser deflate. El fallo real residía en la reserva del búfer de trabajo de Chorba dentro de la vía CRC-32, compilado 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 fallo que se lleva por delante todo el proceso no deja nada que una sesión de depurador Delphi normal pueda capturar, así que el equipo recurrió a puntos de control GetTickCount colocados alrededor de cada llamada sospechosa y a una bisección manual a lo largo de la vía de guardado, acotando qué operación estaba en curso en el momento en que el proceso murió. Junto a 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 arriba en la cadena. Solo después de que ambas comprobaciones salieran limpias, la investigación se decantó por 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 nunca a propósito, y tampoco se entrega del modo en que Windows entrega una violación de acceso o una división por cero. Aflora como un fallo de página de guarda de hardware, reportado a través del mismo mecanismo de manejo de excepciones estructurado sobre el que se construye el try/except de Delphi, pero en el instante exacto en que se dispara normalmente no queda espacio de pila para ejecutar un gestor, deshacer código de limpieza, ni siquiera terminar de reportar el fallo con limpieza. En un hilo de trabajo que solo lleva la reserva por defecto de 1 MB, con un búfer de trabajo de ese tamaño que ya ha 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 parece 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 llegó a tener nunca una ocasión fiable de ejecutarse, y el operador vio un proceso muerto sin ninguna entrada de registro a nivel de aplicación en absoluto, exactamente lo que describía el ticket de soporte original

La solución: alimentar CRC32 en porciones de 64 KB en lugar de una llamada gigante

La solución que distribuyó HotXLS no cambia nada del propio zlib-ng ni del nivel de compresión usado para escribir el libro. ZLibCRC32 ahora recorre la entrada en porciones fijas de 64 KB, 65536 bytes cada una, llamando a crc32 de zlib-ng una vez por porción 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 acumulado a lo largo de varias porciones es idéntico bit a bit a uno calculado en una única 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 de la llamada SaveAs circundante tuvo que cambiar para que esto funcionara, y nada de las entradas ZIP que escribe HotXLS cambió tampoco: el valor CRC-32 que acaba en la cabecera de archivo local y en el directorio central es exactamente el valor que habría producido una única llamada gigante, simplemente ensamblado a partir de piezas más pequeñas. Degradar zlib-ng o recaer en una implementación CRC-32 más lenta y con poca reserva de memoria también habría evitado el fallo, pero a un coste real para cada archivo que nunca se acercó al umbral en primer lugar, razón por la cual no se distribuyó ninguna de las dos

Qué significa esto si llamáis a zlib-ng desde vuestros propios hilos de trabajo

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

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