Copy-on-write for managed strings on cross targets (i386 / ARM32 / AArch64)
- Type: feature
- Status: done
- Owner: codex
- Unblocks: feature-cross-selfhost-i386, feature-cross-selfhost-arm32, feature-cross-selfhost-aarch64
- Opened: 2026-06-13 (split out of i386 self-host burn-down)
Problem
The cross backends (i386, ARM32, AArch64) do not implement copy-on-write
for managed AnsiString writes. Only the x86-64 backend does. So a write
through a shared string handle mutates the shared data in place instead of
first making the target unique.
This is already acknowledged in compiler/ir_codegen386.inc (IR_INDEX comment:
"Managed strings and dynamic arrays (which need copy-on-write) aren't available
on i386 yet, so no COW path here.") and the ARM32/AArch64 equivalents.
Why it blocks self-host
compiler.pas's own LowerCase (parser.inc ~6199) is the trigger:
function LowerCase(const s: ansistring): ansistring;
var i: integer; res: ansistring;
begin
res := s; { shares s's handle, refcount bump }
for i := 1 to Length(res) do
if res[i] in ['A'..'Z'] then
res[i] := Chr(Ord(res[i]) + 32); { in-place write — needs COW first }
LowerCase := res;
end;
With no COW, res[i] := ... mutates the buffer still aliased by s. In the
compiler this corrupts a proc's call name: HeapMmap is folded to
heapmmap in a buffer that is also the case-preserved decl name, so
MatchProcCall's exact Procs[i].Name = name misses and you get:
pascal26:119: error: no overload of heapmmap matches these arguments
(heapmmap is reached on the empty-program startup path via the heap RTL.)
Minimal repro
/tmp/lc.pas:
program lc;
function LowerCase(const s: ansistring): ansistring;
var i: integer; res: ansistring;
begin
res := s;
for i := 1 to Length(res) do
if res[i] in ['A'..'Z'] then res[i] := Chr(Ord(res[i]) + 32);
LowerCase := res;
end;
var x: ansistring;
begin
x := 'HeapMmap';
writeln('orig=', x);
writeln('lower=', LowerCase(x));
x[1] := 'Z';
writeln('afterwrite=', x);
end.
Build/run per target (tools/run_target.sh <arch> <bin>; i386 runs natively):
x86_64 : afterwrite=ZeapMmap <- correct (x stayed 'HeapMmap')
i386 : afterwrite=Zeapmmap <- BUG: LowerCase mutated x to 'heapmmap'
arm32 : afterwrite=Zeapmmap <- BUG: same
aarch64: segfaults inside LowerCase itself (additional/earlier string bug)
So: i386 and ARM32 share exactly this COW gap; AArch64 has at least this plus an earlier crash — investigate AArch64 separately once COW lands, it may be a second bug on top.
Scope
- Implement AnsiStrUnique-style copy-on-write before an in-place managed-string
write on i386, ARM32, AArch64, mirroring the x86-64 path.
- x86-64 reference:
InLValueWriteflag (defs.inc:776) drives IR_LEA / index lvalue handling inir_codegen.inc; the runtime helper is incompiler/builtin/builtinheap.pas(searchUnique). Cross backends do not trackInLValueWritetoday — wiring it (or an equivalent lvalue-write signal) into the cross IR_INDEX / IR_STORE paths is part of the work.
- x86-64 reference:
- Cover the two write forms: string index write
s[i] := c, and any other in-place mutation that assumes single ownership. - Audit
res := sshare/refcount semantics on the cross targets while here (the alias is what makes the in-place write dangerous).
Acceptance
/tmp/lc.pasprintsafterwrite=ZeapMmap(x unchanged by LowerCase) on i386, ARM32, AArch64 — matching x86-64.make testandmake test-i386 test-arm32 test-aarch64stay green; add a focused COW oracle test per target.- Re-probe the full self-host chain afterwards (it is the next wall for both
feature-cross-selfhost-i386andfeature-cross-selfhost-arm32).
Context / where to look
compiler/ir_codegen386.inc— IR_INDEX (no-COW comment), IR_LEA, IR_STORE_*.compiler/ir_codegen_arm32.inc,compiler/ir_codegen_aarch64.inc— peers.compiler/ir_codegen.inc— x86-64 IR_LEA / IR_DYNUNIQUE / InLValueWrite path to mirror;IR_SLOTADDRis the unconditional slot-address node.compiler/builtin/builtinheap.pas— runtime string helpers (the place anAnsiStrUnique(handle)would live; refcount at[p-16], length[p-8]).- Debugging: i386 binaries run natively (ia32) so plain
gdbworks (no QEMU); binaries are stripped (--emit-objis xtensa/riscv only). Usegdb -ex starti -ex 'disassemble A,B', map crash addr to a proc by structure, reproduce in a tiny.pas, diff--target=i386vs--target=x86_64output.
Log
- 2026-06-13 — opened. i386 + ARM32 confirmed to share the no-COW bug via the repro above; AArch64 has an additional earlier string crash. This is the current wall for the i386 (and very likely ARM32) self-host tickets after the 2026-06-13 i386 codegen burn-down (7 fixes; see feature-cross-selfhost-i386).
- 2026-06-13 — claimed by Codex; starting with the i386
AnsiStringindex-write COW slice because it is the current i386 self-host wall. - 2026-06-13 — i386 slice implemented. Added
PXXStrUnique, wired i386IR_LEA/IR_INDEX/IR_STORE_MEMlvalue-write handling sos[i] := cclone-if-shared publishes a unique handle, and addedtest/test_cross_string_cow.pastomake test-i386. While re-probing self-host, also hardened small 64-bit emission/patching paths that i386 self-host lowered as duplicated low dwords (EmitI64, ELFwriteU64, code/dataPatch*U64, string-table length headers) and removed executable float literals from the cross-runtime float writer. Result:make test-i386passes and the i386-generated compiler now emits byte- identical x86-64test/hello.pasoutput under-dPXX_MANAGED_STRING. ARM32 and AArch64 COW remain pending. - 2026-06-14 — i386 COW proven end-to-end: feature-cross-selfhost-i386 is DONE
(full
compiler.pas -> i386self-fixedpoint byte-identical), so the i386 COW path (index-writePXXStrUnique) is exercised through the whole compiler. ARM32 and AArch64 COW remain pending (this ticket stays open for them). - 2026-06-14 — AArch64 index-write COW slice implemented. Mirrored the i386
write-mode
IR_INDEXpath:IR_STORE_MEMnow emits destination addresses withInLValueWrite, and managed-string byte writes callPXXStrUniquebefore indexing. Addedtest/test_cross_string_cow.pastomake test-aarch64.make test-aarch64andmake testpass. Full AArch64 compiler self-host now gets past the LowerCase/COW and stale hidden-temp walls but still crashes later while parsingbuiltinheap.pas; track that underfeature-cross-selfhost-aarch64. - 2026-06-15 — ARM32 index-write COW slice implemented (commit 2fbaca4);
ticket DONE. Mirrored
the i386/AArch64 path in
ir_codegen_arm32.inc:IR_STORE_MEM(string/float/ scalar dest) now emits destination addresses withInLValueWrite := True, and theIR_INDEXmanaged-string write branch callsPXXStrUnique(r0 = slot addr → unique handle, AAPCS arg0) before computing the byte address; read position loads the handle (with the by-ref-param deref case mirrored). Addedtest/test_cross_string_cow.pastomake test-arm32. Verified the ticket repro now printsafterwrite=ZeapMmapon all four targets (x86_64 / i386 / arm32 / aarch64);make test test-i386 test-arm32 test-aarch64all green. COW is complete for every cross target. Remaining self-host crashes are tracked infeature-cross-selfhost-arm32/feature-cross-selfhost-aarch64, not here.