p^ on a frozen string is unlowered on wasm32 (read side only)
Found 2026-09-02 by the wasm32 lane while landing the tyShortString byte-prefix
conversion (0973746b0). Not caused by it — see the control below.
Repro
program deref;
type TS = string[10]; PS = ^TS;
var s: TS; p: PS;
begin
s := 'hello'; p := @s;
WriteLn('Length(s) ', Length(s));
WriteLn('Length(p^) ', Length(p^));
end.
| build | default | -dPXX_SHORTSTRING |
|---|---|---|
| native x86-64 | 5 / 5 |
5 / 122511465736197 |
| wasm32 (wasmtime) | trap: unreachable | trap: unreachable |
The native -dPXX_SHORTSTRING number is a different, known defect
(IRFrozenKindOfAddr, frankb-a9's) and is only in the table to show the two are
not the same thing. wasm32 never reaches that question.
The control that makes this pre-existing rather than mine
The same program, same target, compiled with
stable_linux_amd64/default/pinned (1eec4dc5e0a74c69), traps identically at
default. The conversion changed nothing here in either direction.
Why the WRITE side is not part of this ticket
p^ := c through the same pointer does lower on wasm32. It is wrong under
-dPXX_SHORTSTRING, but for the unrelated shared-walker reason, and that half
is tracked with the walker. Precisely:
p^ := c with c = 'X', under -dPXX_SHORTSTRING, bytes at @s
native : 255 0 0 0 0 0 0 0 0 0 0
wasm32 : 1 0 0 0 0 0 0 0 88 0 0 { 'X' at offset 8, not 1 }
So the two directions have different causes and only the read one is a wasm32 lowering gap. Do not fix them together.
The write half is a defect 0973746b0 exposes, not introduces. It created a
new caller of IRFrozenKindOfAddr (WasmEmitStoreMem asks it for the
destination kind), and that function answers tyString for a p^ destination, so
the backend does the wide thing correctly with a wrong answer. Nothing in that
commit's own acceptance set routes through a typed-pointer deref — the 8/8
oracle matrix, the 35/35 byte-identity and its positive control are all
unaffected — which is why the conversion was not re-verified over it.
The wasm32 corruption differs from native's while sharing the cause, and
that is useful rather than confusing: a fix that turns both into 1 88 is
checkable on two shapes instead of one. Note also that wasm32's is the quieter
of the two — the low byte of the 8-byte word reads back as a correct
Length of 1 — so a fix asserting on Length passes while still broken.
Method note, because this ticket was nearly filed with the opposite claim
The first version of this finding said wasm32 did not acquire the walker defect. The read side had been measured, and the sentence "the shape never reaches the width question" was allowed to cover a write that had not been. It was load-bearing for two minutes: another lane's fix was scoped to six targets on it before the correction landed.
The reusable part is not "measure both directions". It is that a mechanism
sounds like it generalises and an absence does not — so reporting the bare
absence would have been SAFER here than reporting the correct why for half the
shape. A why invites the reader to extend it; a didn't see it does not. When
you have a mechanism for part of a shape, name the part.
The consequence that matters to someone else
IRFrozenKindOfAddr's read-side fix cannot turn wasm32 green on a Length(p^)
row — wasm32 traps before the width is consulted. A matrix that expects seven
green targets on that row will read wasm32's trap as the fix failing. wasm32 IS
a site for the WRITE-side half of that fix; it is not one for the read half.
A SILENT SIBLING WITH THE SAME CAUSE, found 2026-09-04
bug-a-wasm32-a-frozen-string-through-a-pointer-in-a-record-field-compares-as-the-fields-address.
r.NamePtr^ = 'lit' for NamePtr: ^string[N] in a RECORD FIELD answers FALSE
on wasm32 and TRUE on the other five targets — no gap, no trap, and the same
expression PRINTS the string correctly. It is why GetClass returns nil on
wasm32.
So the p25 rating's premise — "fails loud and produces no wrong value" — is
true of THIS ticket's shape and not of the cause. Read the sibling before
scoping a fix: the closing paragraph below says the address-is-the-value arm is
"right for a string[N] record field", which holds only when the field IS the
string and is exactly wrong when the field HOLDS A POINTER to one.
Where it is
The trap is a WasmUnsupported (unreachable), so it fails loud and produces
no wrong value — which is why this is p25 and not higher. Whoever takes it
should start at WasmEmitLoadMem in compiler/ir_codegen_wasm32.inc: it has a
TypeIsFrozenString arm that treats the address as the whole value, which is
right for a string[N] record field and is not what a ^TS deref needs.
FIXED 2026-09-04 — by the read-side kind fix, not by the wasm32 backend
All four cells of the table above now read 5 / 5:
native default : Length(s) 5 Length(p^) 5
native -dPXX_SHORTSTRING : Length(s) 5 Length(p^) 5 (was 122511465736197)
wasm32 default : Length(s) 5 Length(p^) 5 (was trap: unreachable)
wasm32 -dPXX_SHORTSTRING : Length(s) 5 Length(p^) 5 (was trap: unreachable)
The fix is ASTDerefFrozenTk in compiler/ir.inc, landed for
bug-a-a-pointer-deref-loses-the-shortstring-kind-on-every-target: the address
arm of IRLowerAddress now tags a narrow-frozen deref with the kind the
AN_DEREF node already records, so Length(p^) reaches wasm32's frozen load
arm instead of WasmUnsupported.
Attribution measured, not assumed. This ticket's own repro was re-run on
the PRE-FIX compiler — which is HEAD and therefore already carries the wasm32
frozen-load change 9b67b266d, the change this ticket's closing paragraph
points at — and it still traps (wasm trap: unreachable, exit 134). So
9b67b266d did not close it.
The WRITE half stays out of this, and it was already correct. p^ := c
under -dPXX_SHORTSTRING gives 1 88 ... on native AND wasm32 on the pre-fix
compiler as well as the post-fix one — which is the 1 88 outcome this ticket
predicted a correct fix would produce, arrived at before this change. Whatever
fixed it, it was not this.
Covered going forward by test/test_cross_frozen_ptr_narrow.pas, wired into
test-core on all seven targets: its ptr16 / ptr255 rows are exactly
Length(q^) for q: ^string[N], plus the print, compare, assign, concat and
index columns of the same expression.
Log
- 2026-09-04 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit a50671107.