Artículo técnico

Bugs de Delphi solo en Win64 encontrados al endurecer HotPDF

El código Delphi de Win64 puede fallar donde la misma fuente corre limpio en Win32, y el componente HotPDF Delphi PDF se topó con cinco casos así durante una pasada de endurecimiento reciente: Power(10, N) enlazando al overload de Single, un loop while leyendo un TList.Count viejo, un límite High(Int64) que redondea hacia arriba a 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 exactamente cómo se colaron. Los casos de abajo salen de los importers de 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 de prueba compilados para Win32 y Win64. Si usted 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 resuelve al overload de Single, así que el resultado se computa y devuelve en precisión simple y cualquier cosa por encima de aproximadamente 3.4E38 desborda. En Win32 la misma llamada se enlaza al overload de 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 de Single. La pista es la precisión, no solo el overflow: en Win64, Power(10, 20) devuelve 1.0000000200408773E20, que es exactamente Single(1E20). Un resultado Double imprimiría 1E20. Vimos el mismo enlace con cada compilador Win64 que probamos, desde Delphi 10.3 hasta la versión de compilador 37.0

Lo que pasa después depende de la máscara de excepciones de punto flotante. Delphi 12 y posteriores enmascaran todas las excepciones de punto flotante por defecto, así que el overflow 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 levanta EOverflow. Las aplicaciones que asignan la máscara ellas mismas, y las DLLs cargadas en tales hosts, reciben el comportamiento que el host haya escogido, razón por la cual una librería no puede asumir ninguno de los dos desenlaces

Trampa numérica Win64 de HotPDF donde System.Math Power con argumentos enteros se enlaza al overload de Single, así que Power de 10 a la 20 devuelve 1.0000000200408773E20 en vez de 1E20 y Power de 10 a la 100 da más infinito cuando las excepciones están enmascaradas o EOverflow cuando no lo están
la pérdida de precisión es la delación: si una potencia de diez vuelve con ruido de Single adjunto, ganó el overload equivocado — arme 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 de Single)
  Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));

  // Reproduzca lo que hace Delphi 11, o un host con settings 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 la duración de un test es la forma más barata de ver lo que ve un compilador más viejo o un host estricto. En un compilador moderno con settings por defecto el bug no se cuelga, produce infinitos y ceros, y esos son mucho más difíciles de distinguir en el log de un test. Restaure la máscara previa en finally: la máscara es estado por thread, y el resto de la corrida de tests hereda lo que usted deje

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

Los lectores de paths de SVG y XPS de HotPDF comparten un scanner de números, y ese scanner escalaba la mantisa con Power(10, Exponent) en cuanto leía un exponente. Cualquier SVG pasado a THotPDF.ImportSVGFormXObject (el punto de entrada detrás de 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 había topeado el exponente en 100 y rechazado valores que pasaran de 1E300, lo que parecía suficiente: 1E100 está a años luz del límite de Double de cerca de 1.8E308. En Win64 igual desbordaba, porque el cómputo nunca ocurría en Double para nada. Desde v2.770.155 el scanner construye la potencia de diez por su cuenta, y números como 1e-100, o una mantisa larga con un exponente negativo grande, se leen como su valor real en vez 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 arma con multiplicaciones Double. Un loop de cuando mucho 100 multiplicaciones no cuesta nada al lado de escanear el texto que la rodea, 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 de 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 un Double exacto
  Result := True;
end;

Tres detalles cargan con el peso. El chequeo de rango usa dos comparaciones en vez de Abs(Exponent) <= 100, porque Abs(Low(Integer)) sigue siendo negativa y se pasaría de largo derechito. Los exponentes negativos dividen por la escala en vez de multiplicar por un 1E-100 precomputado, que no tiene un Double exacto y sumaría un paso de redondeo más. Y el pre-chequeo de Log10 rechaza resultados fuera del rango de Double antes de que la multiplicación tenga oportunidad de desbordar

Sea claro sobre lo que el loop entrega a cambio. Las potencias de diez hasta 1E22 son exactas en Double; más allá de eso cada multiplicación redondea, y después de 100 de ellas la escala queda a unas pocas unidades del último lugar de la 1E100 correctamente redondeada. 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 alcanza, y necesita un algoritmo de conversión correctamente redondeado en su lugar

Cuando dcc64 lee un TList.Count viejo en un loop while

Observamos al compilador Win64 (dcc64, versión de compilador 37.0) generar código para un loop while List.Count > Start do que borraba desde el final de la lista y comparaba contra un temporal del stack en vez de re-leer Count. La reescritura que lo arregló fue un loop for ... downto, cuyos límites se evalúan exactamente una vez por definición

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

// La forma que vimos mal compilada 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;

// Reemplazo: los límites se evalúan una vez, no hay temporal que se quede viejo
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 loop y el Count leído dentro del cuerpo compartían un slot del stack. La condición comparaba contra ese slot al entrar, antes de que cualquier cosa lo escribiera, y nada lo refrescaba después del Delete. Cuando un grupo no había creado soft masks propias, el cuerpo corría de todos modos y le pedía el item -1 a una lista vacía, así que en builds de 64 bits cada página que contuviera tal transparency group fallaba con EListError. El código Win32 para la misma fuente era correcto, y v2.770.1 reemplazó el loop

Trampa de codegen Win64 en HotPDF en la limpieza del renderer: un loop while que re-leía TList.Count compartía un slot de stack entre la condición y el cuerpo, dcc64 nunca lo refrescaba tras Delete, los transparency groups vacíos liberaban el item -1 y levantaban EListError, y la corrección es un loop for downto cuyos límites se evalúan una vez
la lección práctica cuesta menos que la causa raíz: los loops downto de límites fijos no pueden quedarse viejos, y el trabajo del renderer no termina hasta que dcc64 corre la suite

No lo hemos reducido a una reproducción mínima, y un loop standalone pequeño como DropMasksWhile bien puede compilar correctamente; el try/finally circundante y los loops anidados parecen importar. Trátelo como 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 loop cuya condición re-lee el conteo de una colección mientras el cuerpo la achica vale la pena reescribirlo como un for ... downto de límites fijos, y los cambios al renderer necesitan una corrida completa de tests en Win64, no solo Win32

Localizar un crash que solo muestra el build Win64 optimizado

La falla solo se reproducía en el build Win64 optimizado, así que la ubicación salió de herramientas fuera del IDE. Un pequeño programa de prueba registró un vectored exception handler con AddVectoredExceptionHandler, capturó el stack en la primera excepción con RtlCaptureStackBackTrace, y tradujo 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 a la comparación leyendo un slot del stack, [rbp+0x298], que solo se escribía dentro del cuerpo del loop. Ese es el nivel de evidencia que usted quiere antes de culpar al compilador, y tomó menos tiempo que pisar el código de un build release

¿Por qué High(Int64) no es un límite superior seguro para un Double?

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

Win32 lo esconde por la misma razón por la que escondió 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 correctamente compara mayor. Win64 no tiene un tipo más ancho al cual recurrir. La conversión fuera de rango tampoco es bonita: en nuestros tests de Win64 Round(2^63) devolvía Low(Int64), un volteamiento de signo silencioso, fuera exInvalidOp estuviera enmascarado o no. Win32 devuelve el mismo valor cuando está enmascarado y levanta EInvalidOp cuando no

Trampa del límite Int64 en HotPDF: un Double no puede representar High(Int64), así que una comparación Win64 convierte el límite hacia arriba a 2^63, D igual a 2^63 pasa el chequeo y Round devuelve silenciosamente Low(Int64), mientras que Win32 compara en Extended de 80 bits donde el límite es exacto y la misma comparación es False
una conversión es todo el bug: el límite redondea justo al valor que usted está excluyendo, así que escriba el techo como un literal con un menor estricto
ExpresiónWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), excepciones enmascaradas (default de 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 detrás de los valores de jobs de documentos. JSON no pone límite de rango a los números, y el serializador viejo convertía cualquier valor con Frac(Value) = 0 en un entero con Round, así que un 1e19 perfectamente legal se volvía o un entero equivocado o una excepción, según la máscara. Desde v2.770.169 un número entero se escribe como integer solo cuando cabe en Int64, todo lo demás conserva su texto de punto flotante, y los getters de enteros devuelven el default del caller para valores fuera de rango en vez de uno enrollado

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 callers 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;

El límite 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 toda plataforma. El límite inferior puede usar >= porque -2^63 es exactamente Low(Int64). Testear IsNan y IsInfinite primero, con evaluación de corto circuito, mantiene a NaN y a los infinitos lejos de Frac y de las comparaciones, que pueden levantar EInvalidOp cuando el host lo ha desenmascarado

¿Cuántos dígitos le da de verdad el 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 siempre escribe un punto como separador decimal sin importar el locale. Si HotPDF en FPC es 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 el pedido de 17 dígitos, pero los dos targets de Delphi todavía discrepan en la salida: FloatToStrF(0.1, ffGeneral, 17, 0) da 0.10000000000000001 en Win32 y 0.1 en Win64. La RTL de Win64 también puede introducir un error de redondeo en el último dígito tanto al formatear como al parsear, así que más dígitos achican la brecha sin garantizar que cada patrón de bits de un Double sobreviva un viaje de ida y vuelta por texto. La documentación de HotPDF no hace tal promesa, y la suya tampoco debería salvo que usted despache un formateador y parser correctamente redondeados propios. Pase TFormatSettings.Invariant, o reemplace el separador usted mismo en versiones de Delphi más viejas, así un locale alemán o francés no escribe una coma dentro del JSON

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

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

TList.Count dispara el mismo error desde Delphi 12, donde la propiedad se volvió NativeInt; Delphi 11 todavía la declara como Integer. El Length de un string devuelve Integer en ambas plataformas y no se ve afectado, razón por la cual el error aparece en algunas unidades 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 va a decir que su build Win64 está roto hasta que alguien más lo intente

Checklist de porte a Win64 para código numérico de Delphi

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

Las correcciones del lado de la librería descritas aquí están todas en HotPDF desde v2.770.169, así que la importación de SVG, la conversión de XPS, el renderizado de transparencias y el manejo de jobs JSON ahora se comportan 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 HotPDF Delphi PDF tiene las descargas y la lista completa de funciones