El mismo código fuente de Object Pascal puede comportarse de manera diferente bajo Delphi y FPC/Lazarus en cuatro formas que afectan repetidamente al código del componente PDFium: FPC elimina las variables temporales de registros resultantes de funciones antes de que una prueba de pertenencia in termine de leerlas, dcc32 se distribuye con la verificación de rango desactivada por lo que los índices de matriz fuera de los límites leen basura silenciosamente, solo Delphi 13 acepta la asignación de una array of Byte anónima a TBytes sin una conversión, y la concatenación AnsiString de Delphi puede destruir bytes iguales o superiores a $80 a través de una conversión implícita de ida y vuelta de la página de códigos. Cada una produce una suite de pruebas que está en verde en un compilador y en rojo, o peor aún, que es silenciosamente incorrecta, en el otro
Si está configurando un proyecto de doble compilador por primera vez, la guía del visor Lazarus y FPC cubre el camino feliz: paquetes, rutas de búsqueda y cómo colocar una ventana de renderizado en pantalla. Este artículo es lo opuesto a un tutorial. Es la lista de problemas con los que nos topamos después de que el camino feliz funcionó, cuando el sistema de CI estaba en verde bajo FPC y verde bajo Delphi, y luego un cambio que pasó en un lado falló en el otro. Cada trampa oculta a continuación proviene de una falla real en la suite de pruebas PDFiumPas o en sus demostraciones, con la investigación a nivel de confirmación (commit) condensada en una reproducción mínima, la causa raíz y la solución en la que nos estandarizamos
¿Por qué un conjunto se lee vacío bajo FPC pero no en Delphi?
La explicación breve: FPC puede finalizar la variable temporal que contiene el registro resultante de una función antes de que termine una expresión que lee un campo de ese resultado, por lo que X in Func().Issues puede evaluar la pertenencia contra un conjunto que ya ha sido liberado, mientras que la expresión equivalente en Delphi funciona correctamente. Nuestras pruebas de conformidad PDF/E se toparon con esto en su primera versión. El validador devuelve un registro cuyo campo Issues es un conjunto de banderas de violación, y las aserciones incluían la llamada en la misma línea
// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// Reliable on both compilers: pin the result to a local first
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
La forma en línea leía el conjunto como vacío bajo FPC, por lo que cada aserción que esperaba una bandera fallaba, mientras que la compilación idéntica en Delphi pasaba con éxito. La causa raíz es una diferencia en cómo los dos compiladores administran la vida útil de las variables temporales resultantes de funciones dentro de expresiones más grandes: Delphi mantiene viva la variable temporal hasta el final de la instrucción, mientras que la eliminación por parte de FPC de la variable temporal de registro puede competir con el operador de pertenencia al conjunto que todavía la está leyendo. Ya habíamos documentado este mismo comportamiento una vez antes, en un comentario sobre el ayudante FlagPresent helper en la unidad de prueba PDF/A, y luego volvimos a introducir el error de todos modos al escribir nuevas pruebas desde cero, lo que demuestra qué tan natural parece la forma incorrecta. La solución es mecánica y vale la pena adoptarla como regla general: nunca encadene un acceso a un campo o una prueba de conjunto directamente a una llamada de función que devuelva un registro; asigne primero el resultado a una variable local y luego lea el campo. Cuesta una línea y elimina toda una clase de inestabilidad dependiente del compilador
¿Por qué Delphi acepta un índice de matriz que FPC se niega a compilar?
La explicación breve: dcc32 compila un índice fuera de rango en una matriz de límites fijos y, con la comprobación de rango desactivada de forma predeterminada, lee o escribe memoria adyacente en tiempo de ejecución sin ningún error, mientras que FPC rechaza el mismo índice en tiempo de compilación. El componente PDFium declara los puntos del cuadrilátero como una matriz basada en 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, coincidiendo con la forma en que se numeran habitualmente las entradas QuadPoints de PDF. Una demostración que la llenaba con un bucle basado en 0 funcionó durante meses en Delphi
var
I: Integer;
begin
for I := 0 to 3 do // wrong: the array is [1..4]
Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
// silently touches adjacent memory
// FPC: compile-time range check error
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // correct on both compilers
end;
La compilación de Delphi fue un falso positivo: con la comprobación de rango desactivada, que es el valor predeterminado de dcc32, el índice 0 caía en cualquier campo que precediera a la matriz en el registro, y la demostración parecía ejecutarse correctamente. La adaptación de la misma demostración a Lazarus produjo un error inmediato de verificación de rango en tiempo de compilación por parte de FPC, y corregir el índice expuso luego un segundo error más profundo en la ruta de anotaciones de la biblioteca que las lecturas de basura habían estado enmascarando, el cual se analiza en el artículo sobre anotaciones de QuadPoints. De ese incidente surgieron dos lecciones: primero, prefiera Low() y High() sobre límites literales siempre que el tipo de matriz no esté basado en 0 por construcción; segundo, trate una compilación de FPC, o al menos una compilación de Delphi con {$R+} activado, como una puerta obligatoria de primera ejecución para cualquier demostración o prueba nueva: los valores predeterminados de dcc32 no le advertirán sobre esta clase de error, y que un programa se ejecute no es evidencia de que sea correcto
La asignación de TBytes que solo acepta Delphi 13
La explicación breve: la asignación de un campo declarado como array of Byte anónimo a una variable TBytes compila en Delphi 13 (versión de compilador 37.0) pero falla en Delphi 12 Athens y en todas las versiones anteriores con E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Esto no es tanto una división entre Delphi y FPC sino una división entre Delphi y su propio pasado, pero afecta de la misma manera a la base de código multi-compilador: el compilador más reciente acepta silenciosamente una construcción que todos los demás rechazan
type
TValidator = class
private
FBuffer: array of Byte; // anonymous dynamic array type
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // Delphi 13 only; E2010 on Delphi 12
// Athens and earlier
OrigBytes := TBytes(FBuffer); // compiles everywhere; same byte layout,
// safe hard cast
end;
Distribuimos exactamente esto en una rutina de validación, desarrollada y probada localmente en Delphi 13, donde la conversión implícita fue aceptada silenciosamente. El instalador con el código fuente completo sirve a una gran cantidad de usuarios en Delphi 12 y versiones anteriores, y para ellos la unidad simplemente no compilaba. La solución estructural es usar la conversión de tipo directa (hard cast) mostrada anteriormente, la cual es segura porque una array of Byte anónima y TBytes comparten un diseño de matriz dinámica idéntico, o mejor aún, declarar el campo como un tipo con nombre como TBytes desde el principio para que nunca surja la necesidad de conversión. La solución del proceso es la más importante: una construcción que compila en su cadena de herramientas más nueva no demuestra nada sobre los compiladores más antiguos que ejecutan sus usuarios, y esta categoría de regresión es invisible hasta que realiza la compilación contra cada versión compatible. Nuestros scripts de lanzamiento ahora compilan la biblioteca en toda la matriz de compiladores precisamente porque una compilación local 37.0 no puede detectar una permisividad exclusiva de la versión 13
El byte de AnsiString que desaparece en una máquina Windows en chino
La explicación breve: concatenar un byte sin formato igual o superior a $80 en una variable AnsiString con + puede reemplazar silenciosamente ese byte con ? ($3F) en Delphi, debido a que la expresión realiza una conversión implícita de ida y vuelta de AnsiString a UnicodeString y nuevamente a AnsiString a través de la página de códigos del sistema. Encontramos esto a través de una prueba PDF/A que construye un nombre que contiene un byte $FE aislado, que nunca es un byte inicial de UTF-8 válido, para verificar si el validador marca los nombres que no son UTF-8 válidos según la cláusula 6.1.8 de ISO 19005-2
var
BadName: AnsiString;
begin
// On Delphi with a multi-byte system code page (observed on CP936),
// the concatenation round-trips through UnicodeString and $FE, which
// is not a valid CP936 sequence, comes back as '?' ($3F)
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// Safe: build with an ASCII placeholder, then patch the byte in place;
// indexed assignment into a settled AnsiString does not round-trip
BadName := '/Bad' + #1 + 'Name';
BadName[5] := AnsiChar($FE);
end;
En un sistema Windows en chino que ejecuta la página de códigos 936, la cadena concatenada nunca contenía el byte $FE en absoluto, por lo que la biblioteca correctamente no informó nada y la prueba falló (se puso en rojo) pareciendo un error de la biblioteca. La biblioteca nunca estuvo equivocada: un arnés de FPC que introdujo un PDF que contenía genuinamente el byte $FE obtuvo la bandera esperada. La corrupción ocurrió dentro del ejecutable de prueba de Delphi mientras se evaluaba la expresión de cadena, debido a que el modelo de cadena Unicode-first de Delphi convierte expresiones mezcladas de AnsiString a través de UnicodeString, y $FE no es un byte inicial válido en CP936, por lo que la ida y vuelta lo reemplaza. Sea honesto acerca del límite aquí: en una página de códigos occidental de un solo byte como CP1252, la misma expresión generalmente sobrevive, que es exactamente por qué este error se oculta en la mayoría de las máquinas de desarrollo y surge solo en sistemas del este de Asia o en ejecutores de CI (CI runners) localizados. La regla que adoptamos: nunca construir vectores de prueba binarios que contengan bytes iguales o superiores a $80 mediante la concatenación de AnsiString; parchee los bytes en su lugar después de que la cadena se haya establecido, como se describió anteriormente, o construya el vector en TBytes desde el principio
Qué debe comprobar por defecto un flujo de trabajo de doble compilador
Cuatro trampas ocultas, un patrón: cada compilador le informa sobre un subconjunto de sus errores. El análisis de rango en tiempo de compilación de FPC detectó un índice fuera de límites con el que dcc32 se ejecutó silenciosamente durante meses, y el modelo de cadena Unicode de dcc32 expuso una dependencia de la página de códigos que una compilación de FPC pura orientada a bytes nunca activa. La consecuencia práctica es que ninguna de las tuberías (pipelines) en verde es suficiente por sí sola. La compilación cruzada no es solo una casilla de verificación de portabilidad, es un segundo analizador estático y un segundo modelo de tiempo de ejecución aplicado a la misma fuente, con el mismo espíritu que las comprobaciones de límites defensivas en el artículo sobre el endurecimiento de la ABI y la seguridad de memoria
Las reglas vigentes que surgieron de estos incidentes son lo suficientemente cortas como para memorizarlas: asigne los registros resultantes de funciones a una variable local antes de leer los campos; itere matrices de límites fijos con Low() y High(), y ejecute al menos una compilación de FPC o con verificación de rango activada antes de confiar en cualquier demostración nueva; realice conversiones de tipo explícitas en los campos de matriz dinámica anónimos, o declárelos con tipos con nombre, y compile en toda la matriz de compiladores antes de cada lanzamiento; mantenga los bytes altos sin formato fuera de la concatenación de AnsiString por completo. Ninguno de estos pasos cuesta un esfuerzo medible una vez que se convierten en hábitos, y cada uno cierra un modo de falla que un flujo de trabajo de un solo compilador estructuralmente no puede ver
Los cuatro problemas se encontraron y corrigieron en el transcurso del mantenimiento de PDFium Component, que distribuye el mismo código fuente de Object Pascal para Delphi, C++Builder y FPC/Lazarus y ejecuta sus suites de conformidad y regresión en cada una de esas cadenas de herramientas, por lo que las trampas ocultas de este artículo están protegidas por pruebas en lugar de por la memoria