A frozen string argument is empty through a constructor or a virtual call
Not flag-gated. This is the DEFAULT mode. -dPXX_SHORTSTRING is not needed
to reach it and does not change it.
type
R = record f: string[10]; end;
TB = class
val: AnsiString;
constructor Create(const a: AnsiString);
procedure M(const a: AnsiString);
procedure V(const a: AnsiString); virtual;
end;
constructor TB.Create(const a: AnsiString); begin val := a; end;
procedure TB.M(const a: AnsiString); begin WriteLn('M <', a, '>'); end;
procedure TB.V(const a: AnsiString); begin WriteLn('V <', a, '>'); end;
procedure P(const a: AnsiString); begin WriteLn('P <', a, '>'); end;
var s: string[10]; r: R; o: TB;
begin
s := 'plain'; r.f := 'field';
P(s); P(r.f);
o := TB.Create(s); WriteLn('C1<', o.val, '>');
o := TB.Create(r.f); WriteLn('C2<', o.val, '>');
o.M(s); o.M(r.f);
o.V(s); o.V(r.f);
P('lit'); o.M('lit');
end.
| row | x86-64 | i386 / arm32 / aarch64 / riscv32 |
|---|---|---|
P(s) / P(r.f) — plain routine |
<plain> <field> |
correct |
TB.Create(s) / TB.Create(r.f) |
<plain> <field> |
<> <> |
o.M(s) / o.M(r.f) — non-virtual |
<plain> <field> |
correct |
o.V(s) / o.V(r.f) — VIRTUAL |
<plain> <field> |
<> <> |
| a string LITERAL, either route | correct | correct |
So it is not "frozen strings", not "methods", and not "class arguments": it is the constructor path and the virtual-dispatch path, and only for an operand the frozen-to-managed conversion has to build a handle for. A literal takes a different arm and is fine, which is why every class test in the tree passes.
Reproduced at the pin — not a regression
stable_linux_amd64/default/pinned, i386 and arm32: identical <> rows.
Measured 2026-09-03 while building the acceptance suite for
bug-a-i386-copy-and-pos-segfault-under-the-byte-prefix-mode; that fix and its
x86-64 sibling touch the DIRECT call path only, and the pinned run rules them
out as the cause.
Where it is, and why the small fix is the wrong one
Every backend carries the frozen-to-managed argument conversion in its
Procs[...].Params[n].TypeKind = tyAnsiString ladder — and each backend has
that ladder once per call path. x86-64 has four copies (ordered args, direct
call, constructor, method/indirect) and all four convert. The cross backends
have it in the DIRECT call arm and not in the others: i386's IR_CALL_IND arm
says in its own comment "Same shared decision as the direct and virtual paths"
and then re-derives a ladder that has no string arm at all.
Three call paths x five backends is fifteen copies of one decision, and today
four of them are right. Patching the eleven is the microfix; the ladder has
already grown a by-value SET case and a cdecl RECORD case at one site and not
the others, each found the same way. The conversion is target-independent — it
is "this argument is a frozen buffer and the callee wants a handle" — so the
root-cause fix is to do it ONCE in IRLowerCallArg, where every call funnels
through, and delete the arms. devdocs/dev/ir-as-substrate.md and
normalise-dont-special-case.md both point at that shape.
Whoever takes this should decide that first; the fifteen-copy count IS the finding.
Acceptance
The program above, five targets, BOTH modes, values asserted — every row must print its content. Add the wasm32 and xtensa rows if their runtimes can reach it; they are blank here, not green.
Prio raised 85 -> 92 (coordinator, 2026-09-03)
Reproduced independently at a154b5ec9, compiler 9ce317b156e9, DEFAULT
mode, no flag:
x86-64 i386 / arm32 / aarch64 / riscv32
plain(s) [hello] [hello]
ctor(s) [hello] [] <-- empty
method(s) [hello] [hello]
virtual(s) [hello] [] <-- empty
virtual(lit)[hello] [literal]
Raised above the phase-4 blockers, and the reasoning is the goal rather than the overhaul:
- It is the DEFAULT mode. Every byte-prefix ticket in this family needs
-dPXX_SHORTSTRINGto bite. This one ships today, in the build every consumer of$(PXX_STABLE)uses. - It is four of seven targets on the ctor/virtual rows, and x86-64 is correct
on THOSE — but see the correction below: the proc-var indirect row was empty
on x86-64 too, so that row is five targets, not four. The blindness argument
holds for the rows it was made about and was stated too broadly. — so the dev
loop,
gate.sh quickand the pin are all blind to it by construction. - A constructor taking a string is not an edge case.
the-goal-cross-crossis pxx hosting itself somewhere that is not Linux/x86-64 and compiling DOSBox for such a target. Any OOP Pascal cross-compiled today silently gets empty strings into its constructors.
A literal works through both routes, which is why every class test in the tree passes. That is the guard trap: the population that would catch this is "non-literal string argument through a constructor or virtual call", and nothing in the suite constructs it.
Do not microfix the ladder. franka-29's count is the finding: the conversion
is re-derived once per call path per backend — ordered args, direct,
constructor, method/indirect, ~15 copies of which 4 are right — and the same
ladder has already drifted twice on other axes (a by-value SET case, a cdecl
RECORD case), each landing at ONE site. The conversion is target-independent
("this argument is a frozen buffer and the callee wants a handle"), so the
answer is IRLowerCallArg once and delete the arms. root-cause-over-microfix,
and the overhaul is the smaller job because it deletes cases.
FIXED at the root — the conversion moved into IRLowerCallArg (2026-09-03)
IRLowerCallArg grew the mirror of the arm that was already there. It had the
MANAGED -> FROZEN direction (frozen param, AnsiString arg: hidden frozen temp,
tmp := arg, pass the temp's address); it now has FROZEN -> MANAGED the same
way — a hidden owning tyAnsiString local, tmp := arg lowered through the
ordinary assignment path, and a load of the temp as the argument.
It reuses the assignment path rather than growing a second one. m := s for
a managed m and a frozen s was already correct on all seven backends, so the
conversion is a store plus a load and no backend learns anything new.
Two things this needed that reading would not have found
1. A hand-built IR_STORE_SYM is not the assignment path. The first version
did IRAppend(IR_STORE_SYM, tmp, IRLowerAST(argAST), ...), which looks
equivalent and is not — the store arm wants an address where IRLowerAST gave a
value. Every call OOM'd: pxx: out of memory (heap arena mmap failed), rc=203,
on the one-line repro. Synthesising AN_ASSIGN and lowering THAT is what the
neighbouring arm does, and for this reason.
2. THE ARG NODE'S TAG IS NOT THE VALUE'S. IRAppend(IR_ARG, value, ..., ASTTk[argAST])
— seventeen sites — takes the kind from the AST, so after the conversion the arg
node still said string[10] while carrying a heap handle, and every backend
ladder converted a SECOND time, reading the handle pointer as a [len][chars]
buffer. Same OOM, different cause; the IR dump is what separated them
(3: store_sym tmp <- lea s (tk=23) then 5: arg ... tk=4). Added IRArgTk,
deliberately narrow — it retags only a frozen-typed AST whose lowered value came
back tyAnsiString — and put all seventeen sites through it, so they cannot
disagree about the one question they all ask.
Verified
40 measured cells. Five targets (x86_64 native, four under qemu) x the modes
each program compiles in, values asserted against a written .expected.
| program | modes | result |
|---|---|---|
| all shapes x all call paths | default, -uPXX_MANAGED_STRING |
10/10 PASS |
| variable x all call paths | all four modes | 20/20 PASS |
aggregates via Pos/Copy |
default, -dPXX_SHORTSTRING |
10/10 PASS |
Shapes: variable, record field, field-of-field, array element with a CONSTANT index and with a VARIABLE index, field of an array element. Paths: direct, ordered two-arg, constructor, non-virtual method, virtual (base and overridden), proc-var indirect.
Negative control — a rebuilt pre-fix compiler (009ba51e751c) fails these,
and it corrected the ticket: the proc-var indirect row is empty on x86-64
too, so this was five targets on that path, not four.
AND A LEAK THAT EVERY VALUE ROW ABOVE PASSES WITH FULLY PRESENT
The inline conversions called PXXStrFromLit per call and nothing owned the
result. Measured with tools/assert_no_leak.sh, 3000 calls:
| allocs | frees | |
|---|---|---|
| pre-fix (x86-64, printing CORRECT values on every one) | 3000 | 0 |
| fixed, each of x86-64 / i386 / arm32 / aarch64 / riscv32 | 3000 | 2998 |
An unbounded per-call leak on every target, on the paths that were right.
What is NOT done
The inline backend arms are still there — and the canary pass says they MUST be. See "The canary pass" at the end of this ticket. There are TEN of them, not ~15; five are proven LIVE, and deleting all ten is measurable damage.
Two shapes could not be exercised at all today, and neither is this fix's doing:
- Anything but a plain variable under
-dPXX_SHORTSTRING: overload resolution refuses it (bab799137, frankb-78, unlanded by agreement so this lands first). - A frozen FUNCTION RESULT in any mode: same refusal, and it is the shape that
reaches this conversion by a third route (
Procs[].RetType, the storage kind). -uPXX_MANAGED_STRING+ thePos/Copyintrinsics:builtin/builtin.pasdoes not compile in that mode, at the pin as well, for an unrelated reason (a Char VALUE is not a PChar). Blank, not green.
Corrections and additions after the fix (2026-09-03)
MY "x86-64 IS THE CORRECT ONE" WAS ONE ROW TOO BROAD. franka-29's negative control found the proc-var indirect path empty on x86-64 as well, so that row is five targets. The priority reasoning survives — the ctor and virtual rows really are cross-target-only and really are invisible to an x86-64 dev loop — but the sentence claimed more than was measured. Found by the control, not by anyone's reading, including mine.
IT WAS ALSO AN UNBOUNDED LEAK, ON THE PATHS THAT WERE ALREADY RIGHT. The
inline conversions called PXXStrFromLit per call and nothing owned the result:
pre-fix, x86-64, printing the CORRECT string every time allocs=3000 frees=0
post-fix, each of x86-64/i386/arm32/aarch64/riscv32 allocs=3000 frees=2998
Every value assertion in this family passes against frees=0. This is
exactly the class CLAUDE.md names — a leak does not corrupt, it just never gives
memory back — and it was looked for because the handbook says to. There is now
a wired assert_no_leak row per target.
THE SAME DEFECT EXISTED ONE LEVEL UP, IN THE LAYER THAT FEEDS THE LADDERS.
Seventeen sites built IR_ARG from ASTTk[argAST] — the AST's type, not the
lowered value's. After the conversion the arg node still said string[10]
while carrying a heap handle, so every backend ladder converted a SECOND time
and read the handle pointer as a [len][chars] buffer: out of memory, rc=203.
Seventeen copies of one question, each free to disagree — the same shape as
the fifteen ladders, in the layer above them. Now routed through IRArgTk.
Method note worth more than the fix: that out of memory was hit twice from
two entirely different causes, and reading could not separate them. The IR
dump did, in one line — store_sym tmp <- lea s (tk=23) then arg ... tk=4.
Remaining work — deliberately not done
The ~15 inline backend arms are still standing. They are unreachable for
anything funnelling through IRLowerCallArg, and believed-dead is not
proven-dead; CLAUDE.md is explicit that deleting code you believe is dead is
still wrong. This family has already paid for that mistake once — a session
widened the inline frozen-concat arm, banked it as unreachable, and it was
reachable under -uPXX_MANAGED_STRING.
Deleting them wants a per-backend canary turning each arm into an Error with a
run that must stay green. The ticket stays OPEN for this reason, not because
the bug survives.
The canary pass (2026-09-03) — the arms are LIVE, and the census was wrong
Done as asked: each inline frozen->managed argument arm was replaced by an
Error carrying a per-site id, and a control build with IRLowerCallArg's arm
gated off proved every id can fire before any silence was read as evidence.
Result: five of the ten arms fire. The deletion is rejected. With all ten
removed the self-host fixedpoint still held (1 round) and gate.sh quick went
RED: test/quick_canary_nilpy.npy printed ok 23 and segfaulted where it
should print total ok 36 / 36. Root cause and the full fire table are in
refactor-a-nilpy-const-str-bypasses-both-the-literal-fast-path-and-the-call-arg-funnel:
IRLowerCallArg excludes AN_STR_LIT, which is a Pascal node, so a NilPy
literal reaches the backend arm instead. x = "a" * 3 is the one-line repro.
Three corrections to what this ticket previously claimed
- TEN arms, not ~15. Four on x86-64 (ordered args, direct, constructor, method/indirect) plus the ordered-args deferrability predicate, one each on i386/arm32/aarch64/riscv32/xtensa, none on wasm32. A grep for every other spelling finds nothing outside those, so the census is closed. The two riscv32/xtensa external-C arms are a DIFFERENT conversion (frozen -> PChar) and were never part of this.
- "Unreachable through IRLowerCallArg" was the wrong question. The funnel covers Pascal. It does not cover a frontend whose constant strings are not Pascal AST nodes.
- The comment in
ir.incsaying a literal "already reaches the callee correctly on every target" was scoped to Pascal and worded as a claim about the compiler. Corrected in the same commit as this note.
How the first answer came out wrong, which is the reusable part
The first sweep was 100,560 compiles — the whole test corpus x 6 targets x 4
mode corners x -O0/-O2/-O3 — and reported ZERO fires. It enumerated only the
Pascal half of the corpus (test/*.pas): 1676 files. The corpus also holds
818 .npy, 583 .c, 26 .rs, 6 .zig, none of which were compiled once.
Nearly half the corpus was invisible to the instrument.
The hole and the defect had the same shape. The claim under test was "every call argument now funnels through one function", and the frontends that do NOT funnel are exactly the ones a Pascal-only population cannot contain. A random gap would have been lucky to hide this; that one was guaranteed to. The number was a true statement about Pascal sources and was read as a statement about the compiler.
Two guards that did work, and one thing neither could see:
- Per-site ids. Two arms first shared one label; the corpus fired it and every fire came from one of the two. A probe's identity has to be at least as fine as the decision it feeds.
- Replaying the control-firing rows under the armed binary, requiring
rc=0AND no fire. 12 of 76 came backrc=1— a whole backend's control population refusing to compile, which is a silence that means nothing. (ESP image writer, after codegen;--platform=posixcleared it.) - Neither could see the population hole, because both are aimed INSIDE the
population.
gate.sh quickcaught it on the first test outside — which is an argument for gating before believing a census, not after.
Three ways a source contributes no fire and only one is a finding: never compiled, compiled and refused by a later stage, genuinely dead. A source never ENUMERATED is a fourth, and the quietest — it leaves no rc, no log line, no trace at all.
Log
- 2026-09-03 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 86bc8e33d.