← board

Reading an AnsiString out of a record field or array element is broken on xtensa

Storing a managed string into an aggregate and reading it back gives garbage. riscv32 and x86-64 are both correct; this is xtensa-only.

Repro

--target=xtensa --platform=posix --xtensa-soft-mulhigh, Call0, qemu-xtensa.

program t; type R = record f: AnsiString; end;
var r: R; begin r.f := 'ABCDE'; WriteLn(r.f); end.
{ x86-64: ABCDE    riscv32: ABCDE    xtensa: <empty line> }

program t; var a: array[0..2] of AnsiString;
begin a[1] := 'ABCDE'; WriteLn(a[1]); end.
{ x86-64: ABCDE    riscv32: ABCDE    xtensa: <empty line> }

program t; type R = record f: AnsiString; end;
var r: R; begin r.f := 'ABCDE'; WriteLn(Length(r.f)); end.
{ x86-64: 5        riscv32: 5        xtensa: 1936482630 }

It is NOT a Length bug, and that is the useful part

The obvious reading of the third case is that Length mishandles a field operand — it is the one that looks like the arm-specific defects fixed in [[bug-a-xtensa-has-no-ordered-string-compare-and-sorts-by-heap-handle]]. It is not. Copying the field to a plain local first:

r.f := 'ABCDE'; x := r.f; WriteLn(Length(x));   { xtensa: 1936482630 }

is wrong the same way, and WriteLn(r.f) — no Length anywhere — prints nothing. The value read out of the aggregate is already wrong before anything is done with it. Suspect the field/element load or store of a tyAnsiString, not the consumers.

1936482630 is $736F5F46, bytes 46 5F 6F 73 = "F_os" — a pointer into rodata or a fragment of one, not a heap handle and not a length. Whatever the load produces, it is not the handle that was stored.

Where to start

IR_FIELD on xtensa is IREmitNodeXtensa(base) plus a constant add — it yields an ADDRESS and never derefs, which matches riscv32. So the divergence is more likely in the managed store (store_mem of a tyAnsiString through a field address, including whether it retains) or in the load position that follows it. Compare against riscv32's IR_STORE_MEM/IR_LOAD_MEM handling for tyAnsiString rather than against its IR_FIELD, which is the same.

Scope

Blocks at least test_cross_managed_aggregate_locals and test_cross_openarray_string in [[bug-a-hosted-xtensa-diverges-from-the-oracle-on-21-cross-programs]], and is a plausible cause for the two interface tests there (an interface's fields are the same shape). Filed separately from that ticket because it has a single crisp repro and they do not.

Bound

Object-level plus observable output, hosted profile, Call0, at 3bc9a9303267, compared directly against riscv32 and x86-64 built from the same source. Windowed not checked — it faults earlier for unrelated reasons ([[bug-a-xtensa-windowed-abi-faults-on-frozen-strings-copy-and-dynarray-setlength]]).


ROOT CAUSE — the store, and it was three stores, not one. frankS, 2026-08-30

The ticket's own guess was right and understated. Measured first, by reading the field's raw slot and then [handle-8]:

target slot [slot-8]
x86-64 nonnil 5
riscv32 nonnil 5
xtensa nonnil 1936482630

So the slot held a raw literal buffer address, not a managed handle — the store is wrong, not the load, and no amount of work on Length or IR_FIELD would have found it.

IR_STORE_SYM has dispatched on the destination TYPE since managed strings landed. IR_STORE_MEM — the same store reached through an ADDRESS, which is how every record field, array element and deref is written — had no type dispatch at all on xtensa: a 64-bit branch and then a raw sized word store. Three type classes need more than a word copy and all three were silently wrong through an aggregate:

shape what actually landed in the destination
managed r.f := 'lit' the rodata ADDRESS; Length read [addr-8]
frozen r.g := 'lit' the source address, where [len][chars] belongs
float r.d := i the integer bits, in a Double field

Every other backend has all three; riscv32 has them adjacent in this same node, which is how one probe found all three.

Why it survived: the working spellings are the common ones

r.f := s and r.f := s + 'x' were always right, because in those two shapes the raw source word already IS a handle. Only a literal, a char or a frozen source exposes it. This is the third instance tonight of the same sentence — see [[why-xtensa-was-the-holdout]] — and the one before it, ABIParamSlotHoldsValueAddr, had exactly this structure too.

The float row was measured, not inferred

It was added on riscv32's evidence, and the probe that suggested it faulted somewhere else entirely: WriteLn of a Double SIGBUSes on xtensa for a plain local, no aggregate involved — a separate defect that masked this one. Measured instead by reading the field's raw bits back as an Int64, and confirmed load-bearing by ablation: branch off, r.d := i leaves hi=0 lo=7; branch on, hi=1075576832 lo=0 = $401C0000_00000000 = 7.0, matching both oracles. That WriteLn(Double) fault is filed separately.

Fixed by sharing, not copying

Two helpers lifted out so both stores use one body: EmitStrHandleForStoreXtensa (the four handle-producing source shapes, was inline in IR_STORE_SYM) and EmitFrozenCopyXtensa (the frozen [len][chars] copy against a destination ADDRESS — it was inline against a symbol slot, which is precisely why the address-reached path had nothing to call).

Bound

Hosted profile, Call0, --xtensa-soft-mulhigh, binary 915121e5d0e9, against the x86-64 and riscv32 oracles built from the same source. The 9-case store matrix went 9/9 wrong-or-crashing to 9/9 identical. The 142-source differential went 57 match / 19 differ → 63 / 13, cfail set unchanged at 66, zero regressions; newly green: test_cross_dynarray, test_cross_managed_aggregate_locals, test_cross_openarray_string, test_cross_stack_params, test_interfaces_multi_secondary, test_u64_to_double. Both interface tests the Scope section predicted are among them.

Windowed: the managed stores now work there too; the frozen one still faults, and that fault is pre-existing and not from this change — a plain var f: string[16]; f := 'FROZEN' symbol store, no aggregate anywhere, SIGBUSes under windowed on code this commit does not touch ([[bug-a-xtensa-windowed-abi-faults-on-frozen-strings-copy-and-dynarray-setlength]]).


THE THIRD CLUSTER — indexing, and it did NOT fall out of the store fix

Worth recording because it looked like it should. With the stores fixed, WriteLn(r.f) and Length(r.f) are correct and r.f[1] is still wrong.

Indexing a managed string needs the HANDLE, and three base shapes arrive at IR_INDEX with three different things in the register. Xtensa had the write half of one of them:

base read write
scalar (IR_LEA) IR_LEA already loaded the handle — ok slot addr → PXXStrUnique — ok
by-ref param (IR_LEA, skParam+IsRef) the CALLER'S SLOT ADDRESS — one deref short ok
field / element (IR_FIELD/IR_INDEX, 1-byte stride) the field's slot address — one deref short neither COW nor deref ran; the character store landed in the HANDLE SLOT

Measured, Call0, against both oracles:

xtensa before oracle
d[1], var d: AnsiString 32 65
r.f[1] 32 65
a[0][1] 72 80
r.f[2] := 'z'; WriteLn(r.f) [] [AzCDE]
a[0][2] := 'z'; WriteLn(a[0]) [] [PzRST]

All five now identical to both oracles, on Call0 and on windowed.

What made the by-ref row hard to see: Length(d) and WriteLn(d) on the same parameter were both CORRECT. Only the index was wrong, so the signature read as "the index operator is broken" when the base was. test_cross_var_string_param carried exactly this — it was the last line of that test still diverging after the ABI-predicate fix, and it is now green.

riscv32 and arm32 both have all three rows ([[bug-a-riscv32-setlength-on-string-array-element-loses-length]]). Ported verbatim rather than re-derived: this is the same missing-row shape three times in one night — the ABI predicate, IR_STORE_MEM, IR_INDEX — and re-deriving it a fourth time is how the fourth row goes missing.

Bound and close

Binary 4c878d2df324, hosted Call0 --xtensa-soft-mulhigh, plus the windowed re-run of the index matrix. The 142-source differential: 63 → 64 match, 13 → 12 differ, cfail unchanged at 66, zero regressions; test-xtensa regenerated from the measured list and now runs 64 programs.

The ticket's subject — reading a managed string out of an aggregate — is fixed for stores, reads and indexing, on both ABIs. Resolved. What remains is elsewhere: Write of a real SIGBUSes ([[bug-a-xtensa-write-of-any-real-sigbuses-while-str-of-the-same-value-works]], filed tonight from the probe that misled the float branch), and the 12 residual divergences stay on [[bug-a-hosted-xtensa-diverges-from-the-oracle-on-21-cross-programs]].

Log