Artículo técnico

Bugs de Delphi solo en Win64 hallados endureciendo HotPDF

El código Delphi Win64 puede fallar donde el mismo fuente corre limpio en Win32, y el componente Delphi PDF de HotPDF se topó con cinco casos así durante una pasada reciente de endurecimiento: Power(10, N) bindeando al overload Single, un bucle while leyendo un TList.Count caducado, una cota High(Int64) que redondea hacia arriba hasta 2^63, texto float de 15 dígitos en FPC, y asserts de test que dejan de compilar

Ninguno aparece si usted solo compila y testea Win32, que es justo como se colaron. Los casos de abajo vienen de los importadores SVG y XPS de HotPDF, de su renderer de páginas y de su lector de jobs JSON, y los resultados numéricos citados se reprodujeron con pequeños programas sonda compilados para Win32 y Win64. Si está moviendo una base de código Delphi a 64 bits, cada uno merece un grep

¿Por qué Power(10, 100) desborda solo en Win64?

En Win64, System.Math.Power(10, N) con argumentos enteros se resuelve al overload Single, así que el resultado se calcula y devuelve en precisión simple y todo lo que pase de unos 3.4E38 desborda. En Win32 la misma llamada bindea al overload Extended y corre sobre la FPU x87 con precisión de 80 bits, así que Power(10, 100) es sencillamente 1E100

System.Math declara Power para Extended, Double y Single, más una familia IntPower a juego que Power llama cuando el exponente es un número entero. En Win64, Extended es solo un alias de Double (SizeOf(Extended) = 8), y para dos argumentos enteros el compilador escoge la versión Single. El delator es la precisión, no solo el desborde: en Win64, Power(10, 20) devuelve 1.0000000200408773E20, que es exactamente Single(1E20). Un resultado Double imprimiría 1E20. Vimos el mismo bindeo con cada compilador Win64 que probamos, de Delphi 10.3 a la versión de compilador 37.0

Lo que pasa después depende de la máscara de excepciones de coma flotante. Delphi 12 y posteriores enmascaran por defecto todas las excepciones FP, así que el desborde es silencioso: Power(10, 100) devuelve +Inf y Power(10, -100) devuelve 0. Delphi 11 y anteriores dejan exOverflow sin enmascarar, y la misma llamada lanza EOverflow. Las aplicaciones que fijan la máscara por su cuenta, y las DLL cargadas en tales hosts, obtienen el comportamiento que el host escoja, que es por lo que una biblioteca no puede asumir ningún desenlace

Trampa numérica Win64 de HotPDF donde System.Math Power con argumentos enteros bindea al overload Single, así que Power de 10 a la 20 devuelve 1.0000000200408773E20 en lugar de 1E20 y Power de 10 a la 100 produce más infinito cuando las excepciones están enmascaradas o EOverflow cuando no lo están
la pérdida de precisión es la pista: si una potencia de diez vuelve con ruido de Single pegado, ganó el overload equivocado — constrúyase la escala usted mismo
uses
  System.SysUtils, System.Math;

procedure ShowPowerOverload;
var
  N: Integer;
  OldMask: TArithmeticExceptionMask;
begin
  N := 20;
  // Win32 imprime 1E20; Win64 imprime 1.0000000200408773E20 (overload Single)
  Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));

  // Reproduce lo que hace Delphi 11, o un host con ajustes FP estrictos
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Desenmascarar exOverflow y exInvalidOp durante un test es la manera más barata de ver lo que ve un compilador más viejo o un host estricto. En un compilador moderno con ajustes por defecto el bug no casca, produce infinitos y ceros, y esos son mucho más difíciles de avistar en un log de tests. Restaure la máscara previa en finally: la máscara es estado por hilo, y el resto de la pasada de tests hereda lo que usted deje ahí

Cómo el overload llegó a la importación SVG y XPS de HotPDF

Los lectores de paths SVG y XPS de HotPDF comparten un único escáner de números, y ese escáner escalaba la mantisa con Power(10, Exponent) en cuanto había leído un exponente. Cualquier SVG pasado a THotPDF.ImportSVGFormXObject (el punto de entrada tras importar SVG a PDF como form XObjects reutilizables), y cualquier geometría de path manejada durante la conversión de XPS y OpenXPS a PDF, podía alimentar a esa llamada con una coordenada como 1e100 o 5e99

v2.770.91 ya topaba el exponente en 100 y rechazaba valores que pasarían de 1E300, lo que parecía suficiente: 1E100 está muy lejos del límite Double de unos 1.8E308. En Win64 seguía desbordando, porque el cálculo nunca ocurrió en Double. Desde v2.770.155 el escáner construye la potencia de diez él mismo, y números como 1e-100, o una mantisa larga con un exponente negativo grande, se leen como su valor real en lugar de colapsar a 0

Una potencia de diez segura para exponentes acotados

Cuando el exponente está acotado, la potencia de diez más segura es la que usted construye con multiplicaciones Double. Un bucle de como mucho 100 multiplicaciones no cuesta nada al lado de escanear el texto alrededor, jamás produce un intermedio mayor que la escala final, y se comporta idéntico en Win32, Win64 y Free Pascal

const
  MaxDecimalExponent = 100;

function TryScaleByPowerOf10(const Value: Double; Exponent: Integer;
  out Scaled: Double): Boolean;
var
  Scale: Double;
  I: Integer;
begin
  Scaled := 0;
  Result := False;
  if (Exponent < -MaxDecimalExponent) or (Exponent > MaxDecimalExponent) then
    Exit;
  // Rechace resultados que saldrían del rango Double
  if (Exponent > 0) and (Value <> 0) and
     (Log10(Abs(Value)) + Exponent > 300) then
    Exit;
  Scale := 1.0;
  for I := 1 to Abs(Exponent) do
    Scale := Scale * 10.0;       // jamás supera 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // divida: 1E-100 no tiene Double exacto
  Result := True;
end;

Tres detalles cargan con el peso. El check de rango usa dos comparaciones en lugar de Abs(Exponent) <= 100, porque Abs(Low(Integer)) sigue siendo negativo y colaría directo. Los exponentes negativos dividen por la escala en lugar de multiplicar por un 1E-100 precalculado, que no tiene Double exacto y añadiría un paso de redondeo más. Y la pre-comprobación con Log10 rechaza resultados fuera del rango Double antes de que la multiplicación tenga ocasión de desbordar

Sea claro con lo que el bucle sacrifica. Las potencias de diez hasta 1E22 son exactas en Double; más allá cada multiplicación redondea, y tras 100 de ellas la escala queda a unas pocas unidades del último lugar de la 1E100 redondeada correctamente. Para coordenadas de dibujo eso es invisible. Para una conversión general de texto a double que deba reproducir cada valor bit a bit no basta, y necesita un algoritmo de conversión correctamente redondeado en su lugar

Cuando dcc64 lee un TList.Count caducado en un bucle while

Observamos al compilador Win64 (dcc64, versión de compilador 37.0) generar código para un bucle while List.Count > Start do que borraba desde el final de la lista y comparaba contra un temporal de pila en lugar de releer Count. La reescritura que lo arregló fue un bucle for ... downto, cuyas cotas se evalúan exactamente una vez por definición

El bucle llegó en v2.769.3, que enseñó al código de grupos de transparencia del renderer a mantener vivas a través de un render de dos pasadas las soft masks creadas dentro de un grupo y liberarlas después. La limpieza se sentaba en un bloque finally tras un bucle for de una o dos pasadas, dentro del bucle por tile. Reducido a su forma, el antes y el después quedan así:

// La forma que vimos malcompilada por dcc64 (versión de compilador 37.0)
procedure DropMasksWhile(Masks: TList; Start: Integer);
begin
  while Masks.Count > Start do
  begin
    TObject(Masks[Masks.Count - 1]).Free;
    Masks.Delete(Masks.Count - 1);
  end;
end;

// Sustituto: las cotas se evalúan una vez, no hay temporal que caduque
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count es NativeInt desde Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

En el código Win64 generado, el Count de la condición del bucle y el Count leído dentro del cuerpo compartían una ranura de pila. La condición comparaba contra esa ranura al entrar, antes de que nada la hubiera escrito, y nada la refrescaba tras Delete. Cuando un grupo no había creado soft masks propias, el cuerpo corría igual y le pedía el elemento -1 a una lista vacía, así que en builds de 64 bits toda página que contuviera semejante grupo de transparencia fallaba con EListError. El código Win32 del mismo fuente era correcto, y v2.770.1 sustituyó el bucle

Trampa de codegen Win64 en HotPDF en la limpieza del renderer: un bucle while que relee TList.Count compartía una ranura de pila entre la condición y el cuerpo, dcc64 jamás la refrescó tras Delete, los grupos de transparencia vacíos liberaban el elemento -1 y lanzaban EListError, y el arreglo es un bucle for downto cuyas cotas se evalúan una vez
la lección práctica cuesta menos que la causa raíz: los bucles downto de cota fija no pueden caducar, y el trabajo del renderer no está hecho hasta que dcc64 ha corrido la suite

No lo hemos reducido a una reproducción mínima, y un bucle pequeño aislado como DropMasksWhile bien puede compilar correctamente; el try/finally circundante y los bucles anidados parecen importar. Trátelo como una generación de código que observamos en una versión de compilador, no como un defecto conocido de todo compilador Win64. La lección práctica es más barata que la causa raíz: un bucle cuya condición relee el recuento de una colección mientras el cuerpo encoge esa colección merece reescribirse como un for ... downto de cota fija, y los cambios en el renderer necesitan una pasada completa de tests Win64, no solo Win32

Localizar un crash que solo muestra un build Win64 optimizado

El fallo solo se reproducía en el build Win64 optimizado, así que la ubicación salió de herramientas fuera del IDE. Un pequeño programa sonda registraba un vectored exception handler con AddVectoredExceptionHandler, capturaba la pila en la primera excepción con RtlCaptureStackBackTrace, y traducía las direcciones de retorno a nombres de función usando el map file detallado que el linker escribe con -GD. Desensamblar esa función mostró entonces la comparación leyendo una ranura de pila, [rbp+0x298], que solo se escribía dentro del cuerpo del bucle. Ese es el nivel de evidencia que usted quiere antes de culpar a un compilador, y llevó menos tiempo que pisar un build release con el depurador

¿Por qué High(Int64) no es una cota superior segura para un Double?

Un Double no puede representar High(Int64): convertir 9223372036854775807 a Double redondea hacia arriba exactamente a 2^63, uno más que el mayor Int64. En Win64 esa conversión ocurre dentro de la propia comparación, así que D <= High(Int64) es True para D = 2^63, y el Round o Trunc que viene después desborda

Win32 lo esconde por la misma razón por la que escondía el problema de Power. La comparación corre en precisión Extended de 80 bits con mantisa de 64 bits, donde High(Int64) es exacto y 2^63 compara correctamente como mayor. Win64 no tiene un tipo más ancho al que recaer. La conversión fuera de rango tampoco es bonita: en nuestros tests Win64 Round(2^63) devolvía Low(Int64), un vuelco de signo silencioso, tuviera exInvalidOp máscara o no. Win32 devuelve el mismo valor con máscara y lanza EInvalidOp sin ella

Trampa de la cota Int64 en HotPDF: un Double no puede representar High(Int64), así que una comparación Win64 convierte el límite hacia arriba hasta 2^63, D igual a 2^63 pasa el check y Round devuelve silenciosamente Low(Int64), mientras que Win32 compara en Extended de 80 bits donde la cota es exacta y la misma comparación es False
una conversión es el bug entero: la cota redondea justo sobre el valor que usted está excluyendo, así que escriba el techo como literal con un menor estricto
ExpresiónWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), excepciones enmascaradas (por defecto en Delphi 12+)1E100+Inf
Power(10, 100), exOverflow sin máscara1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp sin máscaraEInvalidOpLow(Int64)

HotPDF se topó con esto en el lector JSON tras los valores de job de documento. JSON no pone límite de rango a los números, y el antiguo serializador convertía cualquier valor con Frac(Value) = 0 en un entero con Round, así que un 1e19 perfectamente legal se convertía en un entero equivocado o en una excepción, según la máscara. Desde v2.770.169 un número entero se escribe como entero solo cuando cabe en Int64, todo lo demás conserva su texto de coma flotante, y los getters de enteros devuelven el valor por defecto del llamador para valores fuera de rango en lugar de uno envuelto

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, exacto en Double y Extended

function TryDoubleToInt64(const Value: Double; out R: Int64): Boolean;
begin
  R := 0;
  Result := not IsNan(Value) and not IsInfinite(Value) and
    (Frac(Value) = 0) and (Value >= -TwoPow63) and (Value < TwoPow63);
  if Result then
    R := Trunc(Value);
end;

function JsonNumberText(const Value: Double): string;
var
  R: Int64;
begin
  // Los llamadores rechazan NaN e infinitos primero: JSON no tiene grafía para ellos
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral se detiene en 15 dígitos
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

La cota superior es el literal 9223372036854775808.0 con un < estricto. Esa constante es 2^63, exacta tanto en Double como en Extended, así que la comparación significa lo mismo en todas las plataformas. La cota inferior puede usar >= porque -2^63 es exactamente Low(Int64). Testear IsNan e IsInfinite primero, con evaluación de cortocircuito, mantiene a NaN y los infinitos lejos de Frac y de las comparaciones, que pueden lanzar EInvalidOp cuando el host lo tiene sin máscara

¿Cuántos dígitos le da de verdad float-a-texto en Win64?

Menos de los que usted pide, en dos compiladores de tres. El FloatToStrF(Value, ffGeneral, 17, 0) de Free Pascal 3.3.1 en Win64 se detiene en 15 dígitos significativos, así que 1/3 vuelve como 0.333333333333333 y dos valores Double distintos pueden serializarse a texto idéntico. Str(Value:24, Text) seguido de Trim produce 17 dígitos significativos en notación científica, 3.3333333333333331E-001 para el mismo valor, y escribe siempre un punto como separador decimal sin importar el locale. Si HotPDF sobre FPC forma parte de su matriz de builds, las notas de soporte Win64 de HotPDF para Free Pascal y Lazarus cubren el resto de las diferencias de plataforma

Delphi acepta la petición de 17 dígitos, pero los dos objetivos Delphi siguen discrepando en la salida: FloatToStrF(0.1, ffGeneral, 17, 0) da 0.10000000000000001 en Win32 y 0.1 en Win64. La RTL Win64 puede además introducir un error de redondeo del último dígito tanto al formatear como al parsear, así que más dígitos estrechan la brecha sin garantizar que cada patrón de bits Double sobreviva a un viaje de ida y vuelta por texto. La documentación de HotPDF no hace semejante promesa, y la suya tampoco debería salvo que usted despache un formateador y un parser correctamente redondeados propios. Pase TFormatSettings.Invariant, o sustituya el separador usted mismo en versiones Delphi más viejas, para que un locale alemán o francés no escriba una coma en el JSON

¿Por qué Assert.AreEqual deja de compilar en Win64?

Assert.AreEqual(3, Length(Arr)) sobre un array dinámico compila para Win32 y falla para Win64 con E2532, "Couldn't infer generic type argument from different argument types", porque Length de un array dinámico devuelve NativeInt en Win64. Con un literal Integer a un lado y un NativeInt de 64 bits al otro, el Assert.AreEqual<T> genérico de DUnitX no logra asentarse en un único T, y la build se detiene

TList.Count dispara el mismo error desde Delphi 12, donde la propiedad pasó a ser NativeInt; Delphi 11 aún la declara como Integer. Length de un string devuelve Integer en ambas plataformas y no se ve afectado, que es por lo que el error aparece en unas units de test y no en otras. Escriba el argumento de tipo explícito, Assert.AreEqual<NativeInt>(3, Length(Arr)), y compile el proyecto de test con dcc64 antes de hacer commit. Una suite que solo compila para Win32 no le dirá que su build Win64 está rota hasta que otro la pruebe

Lista de comprobación para portar código numérico Delphi a Win64

  • Busque llamadas a Power( y IntPower( con argumentos enteros; pase valores tipados Double o construya usted las potencias de diez acotadas
  • Corra los tests numéricos al menos una vez con exOverflow y exInvalidOp retirados vía SetExceptionMask, en Win32 y Win64
  • Escriba la cota superior Int64 como < 9223372036854775808.0, nunca <= High(Int64), y rechace NaN e infinitos antes de cualquier comparación
  • No convierta un número parseado a Int64 solo porque Frac sea 0; los números JSON pueden ser mucho mayores
  • Reescriba los bucles while que releen Count mientras borran elementos como bucles for ... downto de cota fija
  • En FPC Win64, use Str(Value:24, Text) cuando necesite más de 15 dígitos significativos
  • Use Assert.AreEqual<NativeInt> para asserts de Length y Count, y compile los tests con dcc64 antes de hacer commit
  • Tras cualquier cambio en un parser o un renderer, corra la suite de regresión completa en Win32 y Win64, no solo en uno de los dos

Los arreglos del lado de la biblioteca descritos aquí están todos en HotPDF desde v2.770.169, así que la importación SVG, la conversión XPS, el renderizado de transparencia y el manejo de jobs JSON se comportan ya igual en Win64 que en Win32. Si usted genera o procesa archivos PDF desde Delphi o C++Builder para ambas plataformas, la página del componente Delphi PDF de HotPDF tiene las descargas y la lista completa de características