← board

Char vs a frozen string matched only the one shape that carries the legacy tag

Found 2026-09-04 (frankb-78, Track A) while closing the last of the four phase-4 shortstring-flip defects. Two defects in one pair of arms, and only one of them was about the flip.

The crash predates the flip and ships today

var a: array[0..1] of string[8];
begin a[0] := 'X'; WriteLn(a[0] = 'X'); end.

SIGSEGV — measured on stable_linux_amd64/default/pinned, default mode, no flag. A record field is the same shape and crashes identically.

The guard was lhsTk = tyString. That is a test for the tag the IR puts on a frozen string generically, not a test for "is this a frozen string". A plain variable lowers to IR_LEA, which carries the legacy tyString, so s = 'X' matched and was correct. An array element and a record field are tagged with their real kind (tyFixedString / tyShortString), so neither arm claimed them and the comparison fell through to EmitStrCmpReg — which expects two string addresses and was handed the Char's ordinal.

TypeIsFrozenString(lhsTk) is the membership test the arms wanted, and IRStrTkOf is the accessor. Both were already in the file; the sibling concat arm three hundred lines away had been converted to them and this pair had not.

What the flip added

The same arms read the length with mov rcx, [rax] and the character at [rax+8]. Under a byte prefix the first eight bytes are the length byte followed by seven characters, so cmp rcx, 1 never matched and every Char/String comparison took the not-equal arm — in both directions. Fixed at b97167982; this ticket is the shape half of the same pair.

Why it survived a test named for it

test_char_string_equality_both_directions asserts that the two DIRECTIONS AGREE. They agreed — wrongly — on every row: ('a' = s) = (s = 'a') is True when both are False. Only its second column, printing 'a' = s alone, could see it, and the array-element shape it never tried at all.

The test

test/test_frozen_string_char_compare_shapes.pas — five lvalue shapes (variable, shortstring, record field, array element, pointer deref) × both directions × literal and variable Char, plus a negative half and a length-2 half so a guard that only ever says TRUE cannot pass. Every row is a RELATION, so the file carries no per-target width and means the same thing in both prefix modes on all seven backends. .expected is FPC 3.2.2's output. Wired for x86-64, aarch64, arm32 and riscv32, both modes. The pinned compiler's crash on the elem row is its positive control.