A string-alias cast over a Pointer slot is treated as a no-op, so the expression stays a pointer
- Type: bug — silent wrong value, plus a spurious refusal from one cause
- Track: P (Pascal frontend), with an
ir.inchalf — see below - Found while: re-measuring [[feature-embed-pascal-script]], whose remaining wall this is. That ticket recorded only the loud half.
The two faces, both measured 2026-09-05 against fpc 3.2.2 -Mdelphi
type t = AnsiString;
var p: Pointer;
begin
t(p) := 'abc';
writeln(t(p)); { pxx: 4261104 fpc: abc }
SetLength(t(p), 2); { pxx: SetLength expects a string variable in IR codegen
fpc: compiles, prints ab }
end.
The silent one is the dangerous one and is not what the pascal-script ticket
describes: it says the wall is SetLength. A pointer printed as a number is a
plausible-looking value, and this idiom appears 93 times in uPSCompiler.pas
alone — it is how that codebase stores every string.
The boundary, varied rather than assumed
| operand of the cast | SetLength(t(...), 2) |
|---|---|
var s: AnsiString |
works |
p^ where p: ^AnsiString |
works |
record field of type AnsiString |
works |
var p: Pointer |
fails |
Pointer field, reached directly |
fails |
Pointer field through a record pointer |
fails |
So it is not the indirection and not the record — it is that the underlying slot
is Pointer-typed, which is exactly when the cast stops being a no-op and
becomes a reinterpret.
Cause
pasparser_expr.inc, the C4 alias-cast arm (strAliasCast). It exits with
LastExprTk := IntToTypeKind(ASTTk[CurASTNode]) — the OPERAND's own kind. Its
comment says "a value-level no-op, NOT a pointer reinterpret. Keep the operand
node and its own string kind", which assumes the operand is a string. The
tyRecord arm directly below is the model for the other case: it retypes the
node rather than returning it unchanged.
This is the third face of the cause 9339d6661 fixed the second of ("a cast
to a string alias no longer drops the index that follows it"). Same arm, same
assumption, one operand type over.
A retype-only fix was tried and REVERTED — this is the part worth reading
Making the arm retype the node when the operand is not already a string (keeping
the no-op when it is, since that is load-bearing for Pos(tbtstring(' '), s))
is the obvious change and it is not sufficient:
writeln(t(p))then printsabcfollowed by a run of out-of-bounds memory — worse than the wrong number it replaced.Length(t(p))still answers4265208, the pointer value, against fpc's3.SetLength(t(p), 2)still refuses; that check is in IR codegen and does not read the node's retagged kind.
And the store is already correct: t(p) := s; p = Pointer(s) is TRUE, and
Length(s) is 3. So the value in the slot is right and every READ path is
wrong. The fix therefore spans the IR lowering as well as the parser.
CORRECTED 2026-09-05, same day, asked for by frank-optimize before it took
this ticket. This paragraph first said the lowering "re-derives the type from
the SYMBOL rather than from the node", and called it the same durable-column
seam as the pointee-element bug. That was a hypothesis written from the retag
not helping, and the code does not support it as stated. The refusal is
ir_codegen.inc:10778, the final else of the specialId = 101 chain whose
arms are if IRKind[val1Node] = IR_LEA then
else if IRNodeIsFrozenStrAddr(val1Node) — it dispatches on NODE predicates,
not on a symbol. The measured fact underneath is only this: retagging the AST
node's ASTTk did not change the refusal, so whatever those predicates read, it
is not that tag.
Five siblings, so a fix keyed on the x86-64 arm alone will pass a native
gate and leave four targets refusing: ir_codegen386.inc:3284,
ir_codegen_aarch64.inc:3246, ir_codegen_arm32.inc:2613,
ir_codegen_riscv32.inc:2906, ir_codegen_xtensa.inc:3013, each with the same
message under a target prefix.
The comment directly above that chain cites
bug-a-setlength-is-refused-for-any-frozen-string-that-is-not-a-plain-symbol,
and the IRNodeIsFrozenStrAddr arm is its fix. The case here is the next shape
past it — a Pointer-typed slot rather than a frozen-string address — so that
arm is the model and plausibly the place.
Reverted rather than shipped: a half-fix that turns a wrong number into an out-of-bounds read is not an improvement, and CLAUDE.md's rule is to bank the diagnosis and park it rather than microfix.
The diff itself was restored with git checkout -- and not stashed, which was
a mistake — CLAUDE.md says park held work as a patch or a stash, and "reverted
because it was worse" is still held work if the next taker wants it as a
positive control. Reconstructed here so it is not lost: the arm's exit, today
LastExprTk := IntToTypeKind(ASTTk[CurASTNode]);, became
strAliasOpTk := IntToTypeKind(ASTTk[CurASTNode]);
if TypeIsFrozenString(strAliasOpTk) or
(strAliasOpTk = tyAnsiString) or (strAliasOpTk = tyString) then
LastExprTk := strAliasOpTk
else
begin
ASTTk[CurASTNode] := AliasTk[aliasIdx];
LastExprTk := IntToTypeKind(AliasTk[aliasIdx]);
end;
mirroring the tyRecord arm directly below, which already retypes rather than
returning the operand unchanged.
Both halves sit on DUPLICATED arms, and here are the counts
Neither number is in this ticket's cause section and both decide whether a green means anything.
- Five per-target twins of the IR refusal, listed above. A fix keyed on the x86-64 arm passes a native gate and leaves four targets refusing.
- Seven parameter-driven argument loops in the Pascal parsers (counted by
frank-optimize, 2026-09-05, while fixing
TryDelphiBareProcArg, which was called from two of the free-call loops and from NONE of the seven method ones). Requirement 3 below is therefore asserted at whichever loop the test happens to reach, and says nothing about the other six.
Establish both before the fix rather than after — and make the CURRENT compiler fail on each arm you claim to have covered, because reading every hit of a name in the file that declares it is a weaker claim than it feels.
What a fix has to satisfy
writeln(t(p))printsabc;Length(t(p))answers 3.SetLength(t(p), 2)compiles and yieldsab.Pos(tbtstring(' '), s)still binds a string overload — the no-op path for a string operand must survive, it is why that arm exists.- A frozen-string alias operand keeps whatever it does today; only the non-string operand changes.
RESOLVED 2026-09-06 (requirements 1, 3, 4) — requirement 2 SPLIT OUT, because it is not an alias defect
The fix is a ROUTE, not a new lowering, and the measurement that showed it took one probe
AnsiString(p) and String(p) over a Pointer slot have always been
correct — they print abc and answer Length 3 on the unfixed compiler. Only
the ALIAS spelling was wrong. So this is the same two-spellings-one-taught seam
as the rest of this file's history, and the built-in path's own node is the
answer: an AN_PTR_CAST with ASTIVal = -1, tagged with the cast's string kind.
The alias arm now builds exactly that when the operand is a pointer.
That is also why the previous session's retype-in-place was worse than the bug:
ASTTk is not what the lowering reads. The cast NODE is. The reverted diff
was a correct diagnosis of "the tag does not help" and the wrong conclusion from
it.
THE DISCRIMINATOR IS = tyPointer, AND not TypeIsAnyString BREAKS REQUIREMENT 3
Written the obvious way — "if the operand is not already a string, reinterpret" —
this arm passes every pointer row and silently breaks Pos(tbtstring(' '), s),
which is requirement 3 and the reason the no-op exists. The one-character literal
' ' arrives tagged tyChar, so a negative test sweeps it into the reinterpret
and Pos answers nothing. Measured on a build that was otherwise green.
A char operand is a CONVERSION — the built-in String(c) has a whole
AN_STR_FROM_CHAR arm for it. The boundary table in this ticket measured exactly
one failing operand, a Pointer-typed slot, so that is the operand the arm
changes. A wider negative test is not a wider fix; it is a wider blast radius
wearing the same green.
THE STORE WAS NOT ALREADY CORRECT — the ticket measured one of its two shapes
This ticket recorded "the store is already correct: t(p) := s; p = Pointer(s)
is TRUE". That is true for a string variable source and false for a
literal:
t(p) := s; Length(AnsiString(p)) -> 3 (as the ticket says)
t(q) := 'abc'; Length(AnsiString(q)) -> 1073741824 (not measured before)
AnsiString(q) := 'abc'; -> 3 (the built-in spelling)
Nothing at the call site distinguishes them. The variable form writes the live
payload pointer, which happens to be exactly what a raw pointer store produces;
the literal form does not. Fixed by routing the pointer-operand store to the same
ParseCastAsLValueStore tail the built-in spelling takes —
FinishCastAsLValueStore, split out rather than copied, because the C4 arm has
already consumed ( expr ) by the time it knows the operand's kind.
The existing string-operand arm (TS(s) := 'z' → s := v, keeping refcounting)
is untouched and sits directly above the new one.
Requirement 2 is now [[bug-p-setlength-over-a-string-cast-of-a-pointer-slot-has-no-lowering]]
SetLength(t(p), 2) and SetLength(AnsiString(p), 2) fail identically, so
the alias is not in the cause and closing an alias ticket on it would have been
closing it on a fix that had nothing to do with aliases. The parser drops the
cast and hands the classifier a Pointer symbol; forcing the other arm answers
SetLength expects an ARRAY variable instead, so neither classification has a
lowering and it is an ir.inc job with five per-target twins. Probe reverted,
binary sha back to b8985660920b byte-identical.
Landed
compiler/pasparser_expr.inc— the C4 string-alias arm reinterprets atyPointeroperand through the built-in cast node, no-ops everything else.compiler/pasparser_stmt.inc—FinishCastAsLValueStoresplit out ofParseCastAsLValueStore; the C4 store arm routes atyPointeroperand to it.test/test_a_string_alias_cast_over_a_pointer_slot.{pas,expected}— fpc 3.2.2 oracle, byte-identical, eight rows. D–H are controls, green before the fix; E is the positive control for the discriminator; C is the literal store.
Log
- 2026-09-06 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 9c7a6c559.