The arm32-hosted compiler wants 4x the arenas, then dies unchecked at ENOMEM
Measured
qemu-arm -strace, arm32-hosted build of compiler/compiler.pas:
mmap2(NULL,268435456,...) = 0x40802000 <- 14 of these succeed
...
mmap2(NULL,268435456,...) = -1 errno=12 (Cannot allocate memory)
15 calls, every one for the 256 MiB HEAP_ARENA, the 15th refused. 3.5 GB
mapped in a 32-bit address space.
strace, the same source built by the native x86-64 compiler: 4 arenas, and
/usr/bin/time puts peak RSS at 549 MB. Mapping is roughly 2x RSS there.
arenas for compiler.pas |
address space | |
|---|---|---|
| native x86-64 | 4 | 1 GB |
| arm32 (hosted, qemu-user) | 15 (14 granted) | 3.5 GB, then ENOMEM |
either, on a mid-size uses sysutils, math program |
1 | — |
The last row matters: it does not reproduce on a small input, so a reduction will not find it.
The crash, and why it has no error message
Guest core (qemu-arm writes one under ulimit -c; the system gdb has no arm
target):
pc = PXXAlloc+0xc2c instruction: STR r1, [r0]
r0 = 0xfffffff4 = -12 r7 = 0xc0 = 192 = mmap2
r0 is loaded from a local and written through immediately. builtinheap.pas:977
documents this as intended: "PXXAlloc, which does NOT check the result
(deliberately -- on a hosted target a failed mmap returns a negative errno and
the next access faults), so the returned value IS the base of the heap."
Two defects, and they are worth separating
- The appetite. Unexplained, and it is the actual blocker for arm32
self-hosting. 32-bit pointers are half the size; the header is a fixed 24
bytes on every target, so headers cannot account for 3.75x. Leak, poor reuse
across arenas, or per-allocation waste — not yet distinguished. The next
measurement is
-dPXX_ALLOC_CENSUSon both targets: same bytes allocated means a reuse/fragmentation bug; more bytes means a sizing bug. - The unchecked return. Cheap to fix and independent of (1): one compare turns an anonymous SIGSEGV into "out of memory". The existing comment's rationale is about targets with no page protection (wasm32 shipped a heap at address zero), which does not apply to a hosted target that has a real errno to report. Note this is a stated design decision, so changing it is a deliberate reversal, not a bug fix — say so in the commit.
Not to be confused with
[[bug-a-no-cross-target-can-build-the-compiler-itself]]. That ticket's
write-after-free was a one-byte store into a pointer slot on
arm32/riscv32/xtensa, fixed in 5454ef402 and confirmed independently by frankS
against the original symptom on both of its axes. This is what is left after it,
and the heap debugger is silent on it — zero reports of every family, on an
instrument that fires on plant controls for all three.
Gate
make compiler/pascal26 plus the two -strace counts above.
2026-08-31 — the appetite half is explained; this ticket narrowed to the unchecked return
Renamed from bug-a-the-arm32-hosted-compiler-wants-4x-the-arenas-and-dies-unchecked-at-enomem, because both halves of that name became wrong: it is not
arm32 (i386 shows the identical 15-arena failure natively, frankS, 094e41720),
and the appetite is no longer unexplained.
s := s + x is O(n) on x86-64 and O(n^2) on every other backend — 20000 one-char
appends cost 10 allocations on x86-64 and 19780 on i386, arm32, aarch64 and
riscv32 alike. The recogniser and emitter are hand-written x86-64 machine code in
ir_codegen.inc; the runtime half PXXStrAppend, with the doubling that makes
it O(n), is shared and simply never called by anything else. Full measurements:
bug-o-the-in-place-string-append-is-x86-64-only-so-every-other-backend-is-quadratic.
Stated at the strength it was measured, which is not "proven": the mechanism is proven in isolation (ten-line repro, five targets) and the compiler's own allocation profile has its shape — 5931 of the first 19780 allocations above the top census bin carrying 4.4 GB, creeping +8/+24/+40 and never doubling, against the same buffers going 41943064 -> 83886104 on an x86-64 host. What has NOT been done is attributing those 5931 allocations to the append path by instrumenting their call site. Who owns "then what?": whoever lands the append fix re-runs the 32-bit self-build and reports the arena count. If it does not drop to ~4, there is a second consumer here and this ticket widens again.
Host and target were separated by control: an x86-64 host building for
--target=i386 and --target=arm32 completes normally (20.6M allocs, 1.72 GB,
4 arenas), so the target is not the variable — the backend that compiled the
running compiler is.
2026-09-01 — fixed
HeapPtr := HeapMmap(arena) is now followed by
if HeapMmapFailed(HeapPtr) then PXXHeapExhausted;. Three things were less
obvious than "one compare".
1. The test is the errno RANGE, not the sign. The kernel reserves
[-4095,-1] for -errno, but the 32-bit backends widen a syscall return to
Int64 by two different conventions:
| backend | errno range | a high address (bit31 set) |
|---|---|---|
i386 (ir_codegen386.inc) |
sign-extends NEGATIVE | zero-extends POSITIVE |
arm32 (ir_codegen_arm32.inc, mov r1, r0, asr #31) |
sign-extends NEGATIVE | sign-extends NEGATIVE |
So p < 0 — the obvious form, and the one arm32's own comment implies is
correct — would report out-of-memory for a perfectly good mapping at or above
$80000000, which is ordinary in the 3.5 GB space this ticket measured. The
range test is right under both conventions, because a sign-extended high address
lands far BELOW -4095. Do not "simplify" it to a sign test.
p = 0 is the other failure and is not hosted-specific: the wasm/bare arena is
handed out once and HeapMmap returns 0 after, and 0 is the value the terminal
arm's own comment names as the one that fails silently where there is no page
protection.
2. The reporter must not allocate. It runs when the allocator has just
failed, so writeln — which builds a string — would re-enter PXXAlloc. It
writes a byte at a time through PXXSysWrite, exactly as PXXDbgFlush's
reporter does and for the same reason. Halt(203) is FPC's heap-overflow
runtime error, so a caller reading ExitCode sees FPC's number rather than a
pxx invention.
3. It is a REVERSAL, not a bug fix, and the original decision keeps its
scope. HeapMmap's terminal-arm comment justifies not checking with "on a
hosted target a failed mmap returns a negative errno and the next access
faults". That reasoning is about targets with NO page protection, where 0 is the
value that fails silently — the wasm32-heap-at-address-zero case. It does not
apply to a hosted target that has a real errno to report. The Result := -1
arm is unchanged; it now reaches a message instead of a fault.
Measured
Same source, same 200 MB ulimit -v — below the 256 MB HEAP_ARENA, so the
FIRST request is refused and the failure is deterministic rather than dependent
on what the box happens to have free:
| compiler | result |
|---|---|
pinned 992065f21f33 |
Segmentation fault (core dumped), exit 139, no output |
f23f141f997d |
pxx: out of memory (heap arena mmap failed), exit 203 |
Unconstrained, the same program still allocates a gigabyte and exits 0, so the check costs nothing on the success path.
Regression test
test/test_heap_oom_reports.pas, wired in the Makefile beside the other
ulimit tests. It asserts the exit code AND the message: either alone would
pass for the wrong reason — a segfault a shell happened to report as 203, or a
message printed on a path that then carried on. Its positive control is its own
history rather than an assertion: the pinned compiler demonstrably fails it,
measured above.
gate.sh quick GREEN.
Who owns "then what?"
Nothing here, and that is deliberate. The arena APPETITE — why a 32-bit self-build wanted 15 arenas — is [[bug-o-the-in-place-string-append-is-x86-64-only-so-every-other-backend-is-quadratic]], which already carries the open obligation: whoever lands the append fix re-runs the 32-bit self-build and reports the arena count. This ticket never claimed that half. What changes is that if the count does NOT drop, the next person sees a message instead of a core file.
Log
- 2026-09-01 — resolved, commit 4419e1aa7.