← board

--dce on a C program miscompiles on every cross target

Repro

printf '#include <stdio.h>\nint add(int a,int b){return a+b;}\nint main(void){printf("%%d\\n", add(19,23));return 0;}\n' > /tmp/tiny.c
./compiler/pascal26 --dce --target=aarch64 /tmp/tiny.c /tmp/t && tools/run_target.sh aarch64 /tmp/t

The matrix, measured 2026-09-22 at 311ebde9e, compiler 303897e87b55

subject target --dce --no-dce
seven-line C aarch64 SIGSEGV 42
seven-line C arm32 SIGSEGV 42
seven-line C riscv32 empty output, exit 0 42
test/caarch64_aggregate_byval.c aarch64 SIGSEGV correct
test/caarch64_aggregate_byval.c arm32 SIGSEGV correct
test/caarch64_aggregate_byval.c riscv32 empty output correct
C, x86-64 native correct correct
Pascal (for i:=1 to 3 do WriteLn(i*7)) aarch64 / arm32 / riscv32 correct correct

The Pascal row is the control that scopes this. Same pass, same three backends, same flag, and it works — so "--dce is broken on cross targets" is refuted by the same run that finds this. It is the C frontend crossed with a non-host backend.

The x86-64 C row is the control that explains the blindness. Everything that routinely exercises --dce runs there.

Not caused by any default

Reproduced with the pre-promotion binary (c24a11f2beb3, DCE off at -O2) asking for --dce explicitly: identical SIGSEGV on aarch64 and arm32. So this is a shipping path-O3 turns the pass on and has since 2026-08-21 — and not a consequence of anything proposed.

SCOPE — what is NOT exposed, so this does not over-travel

Bare-metal ESP work is not exposed. The exposure is C on a cross target, and the ESP bare fixtures (test_esp_bare*.pas and the rest of that family) are Pascal, which the control row above shows is unaffected on all three targets. What IS exposed is lib/crtl and anything C aimed at riscv32, arm32, aarch64 or xtensa. Written down because "riscv32 + silent + --dce" reads like an ESP emergency and is not one.

riscv32 is the dangerous arm

aarch64 and arm32 crash, which is the cheap case: a location and a signal. riscv32 prints nothing and exits 0. A harness asserting only the exit status would call that a pass, and tools/expect_same.sh catches it solely because it compares output. Two arms of one defect, and the quiet one is the one to keep in mind when judging whether a fix is complete.

THE REGRESSION TEST MUST ASSERT THE OUTPUT (42), NEVER THE EXIT STATUS — and this is a prediction about how the fix will go wrong, not a style note. Whoever takes this will debug on a crashing target, because a SIGSEGV has a location and the silent arm does not. So aarch64 and arm32 will look fixed first, and a fix verified there can leave riscv32 silently wrong while every exit-status assertion passes. Same class as the leak that printed OPENARRAYFRESH OK with 1504 of 3000 arrays leaked: the assertion was physically unable to observe the defect. Assert the bytes.

AND THE TWO ARMS MAY NOT BE ONE CAUSE — SEPARATE THEM BEFORE CALLING EITHER FIXED (frankb-8e, 2026-09-22, taking the ticket). aarch64/arm32 crashing says the entry path went somewhere invalid. riscv32 exiting 0 with no output is consistent with that and equally consistent with a BODY dropped whose absence merely skips the printf — two different defects that the matrix above cannot tell apart, because both produce an empty stdout. The matrix is evidence that something is wrong on three targets, not evidence that it is the same something. Say in the resolution which arm each assertion actually exercises.

Where to look

The known C-rooting fix — bug-a-dce-on-a-c-program-drops-main-because-nothing- roots-the-c-entry-path, closed — records that the C entry stub's call to main is hand-patched with an absolute address, never through CallFix, so the call graph has no edge to it. That fix roots the C entry path. The shape here looks like the same seam on a backend whose entry stub or call patching differs from x86-64's: something reachable only through a hand-patched address, dropped or left unrelocated where the host arrangement happens to survive.

Note the sibling that was just found in the same file: IramCallFix was marked and never compacted, which is one array touched in DceMark and nowhere else (8417dc950). Grep dce.inc for the other tables the C/cross entry path uses before theorising — this family's one omission has now recurred five times.

What it gates

The -O2 promotion. PROMISE is measured at -66% over nine real examples/** programs (4,424,828 -> 1,475,708 bytes) with the self-host fixedpoint converging. PROOF is a full tier, which is RED with 101 hard FAILs until this is fixed. Nothing else stands in the way: bug-a-a-pascal-hello-world-is-63kb-after-emission-size-dce records the rest.

2026-09-22 (frankb-8e) — FIXED. One missing line, and a FOURTH broken target nobody listed

PatchEntryStubCall (symtab.inc) recorded an entry ROOT on all five targets and recorded the call SITE on one. Those are two different questions: RecordEntryRoot keeps the CALLEE alive, RecordCodeRefAt keeps the CALL aimed at it. Only the second was missing, and only on the four cross arms.

The measurement

aarch64, int main(void){printf("%d\n", add(19,23));}, --dce:

IN: 0x0040012c:  ... 9401d4d4        <- bl, imm26 = 0x1d4d4
    target = 0x400138 + 0x75350 = 0x475488
SIGSEGV si_addr=0x475488, si_code=1 (MAPERR), before ANY syscall

0x475488 is inside the pre-DCE code segment (0x400000-0x4c0000) and above the top of the shrunken one (0x430000). The stub branched where main used to be. main was alive and correct the whole time — --dce-why on the broken binary prints main <- [entry stub call], so the root that a previous fix installed was doing its job and was never the issue.

THE FIX IS ONE RECORD, HOISTED

  RecordEntryRoot(procIdx);
  if TargetArch = TARGET_XTENSA then
    RecordCodeRefFull(patchPos, bodyAddr, 1, anchorPc)
  else
    RecordCodeRefFull(patchPos, bodyAddr, 1, -1);

Every encoding this routine writes already had a matching arm in PatchCodeRefSlot — arm32/aarch64 BL via linkReg, riscv32 via PatchRv32LinkSlot, xtensa's literal delta via the anchor. Nothing new had to be written to re-aim them; the pass simply was never told the sites existed. linkReg = 1 because every site here is a CALL. The x86-64 arm ignores it.

It was present, in one branch, and that branch says why it is there: "...and on the rel32 targets, the SITE moves too when a pass compacts the code between here and the body. That is exactly what CodeRef records." True of every target, written inside the else. Hoisted rather than copied into four branches — the same normalisation EmitCallToCode's own header argues for.

A FOURTH TARGET: XTENSA. Measured, not inferred

The report listed aarch64, arm32, riscv32. xtensa was broken identically and is fixed by the same line. Established by stash-and-rebuild, not by reading: without the fix, qemu-xtensa gives SIGSEGV; with it, dce-c-cross 1729 1729 0. It takes the literal-anchor form rather than a branch immediate, so it is the one arm the other three do not exercise.

AND A SECOND CALL SITE, also measured

PatchEntryStubCall has two callers in cparser.inc: the call to main, and the call to __pxx_run_initializers, emitted only when the source mentions environ (CNeedsEnvironInit is a token scan). Both were broken; an environ-using C program segfaulted on aarch64 without the fix and prints correctly with it. One line fixed both because both go through the one routine.

CORRECTION TO THE MATRIX: riscv32 does NOT exit 0

The report has riscv32 -> no output, exit 0. Measured here, riscv32 gives rc=139, SIGSEGV, like the other two — the compiler's rc is 0 and the program's is 139, which looks like the wrapper-versus-job confusion this repo keeps meeting. So there is no quiet arm and the arity claim is simpler than feared: four targets, one cause, one symptom.

That does not retire the warning it came with, and the test is built to it anyway: every leg asserts STDOUT, not the exit status. A dropped body can produce an empty stdout and a clean exit, and a row asserting rc alone would call that a pass.

The enumeration, including the negatives

Promised as a list rather than a diagnosis, because this family has now missed six times by someone checking the tables they happened to think of.

what references a proc body from outside it recorded?
CallFix yes
CodeRef yes
ProcAddrFix yes
MethodFixups yes
IramCallFix yes (8417dc950, frankh-c0)
PatchProgramEntryJump yes, all targets, per its own comment
PatchEntryStubCall root yes / SITE x86-64 only — THIS BUG

Checked and not affected, stated so the denominator is visible:

So the live set was two sites in one routine, and both are fixed.

Verified

target --no-dce --dce image
x86_64 (control) 42 42 336520 -> 78472 (-77%)
aarch64 42 42 799312 -> 209488 (-74%)
arm32 42 42 848504 -> 156280 (-82%)
riscv32 42 42 881248 -> 168544 (-81%)
xtensa 42 42 code 674387 -> 120963

The shrink column is half the claim: a "fix" that switched the pass off would also print 42.

The test, and it FAILS on the unfixed compiler

test/test_dce_c_cross_entry.c in the quick tier, four cross legs plus the x86-64 control, each leg comparing --dce against its own --no-dce leg rather than a fixed string — one field legitimately differs per target, since CNeedsEnvironInit exits early on xtensa so environ is uninitialised there and the third number is 0 rather than 1. Each leg asserts the oracle printed 1729 1729 first, so it cannot pass on a broken reference, and asserts the image shrank, so it cannot pass on a pass that dropped nothing.

Mutation tested: with the fix stashed and the compiler rebuilt, the job goes FAIL; restored, GREEN. It pins the defect rather than merely running beside the repair.

The fixture reaches both call sites (it mentions environ) and prints 1729 twice — not 0, not 1, not a length, not a pointer width.

Log

2026-09-22 — TWO OF MY THREE CORRECTIONS WERE WRONG (frankb-8e, after frankh-c0 re-measured)

The fix is independently confirmed: frankh-c0 reverted symtab.inc to a79934842~1, rebuilt, measured all three cross targets SIGSEGV, restored, rebuilt, measured all three printing 42. Binary back to f7dedaea694f. That is a mutation test from a second seat and a second checkout.

The corrections I attached to the resolution did not hold up as well.

riscv32: the rc claim was right, my EXPLANATION of it was wrong

I wrote that the report's exit 0 was "the compiler's rc read for the program's". It was not. Measured from both seats, in the && chain and in the bare form: a crashing riscv32 program reports 139 either way, and the compiler's rc cannot mask it.

What actually differs is the output:

aarch64  rc=139  out='qemu: uncaught target signal 11 (Segmentation fault) - core dumped'
arm32    rc=139  out='qemu: uncaught target signal 11 (Segmentation fault) - core dumped'
riscv32  rc=139  out=''

qemu-riscv32 prints no crash banner. So the quiet arm was REAL — in the output, which is what the reporter was reading — and the actual error was inferring an exit status from a silence, on the one target whose runner is silent. That is a sharper mistake than the one I invented for it, and it was in my own first repro run, on screen, two lines under two targets that did print a banner. I read past it and then wrote a confident mechanism for something I had already measured.

So "there is no quiet arm" is withdrawn. There is one, it is riscv32, and it is quiet in the channel a human watches.

This also gives the test a reason neither of us had stated: on riscv32 the qemu banner is not available as a tell, so stdout is not merely the better assertion, it is the only one that separates a working leg from a broken one. The Makefile comment asserting my wrong explanation is corrected in the same commit as this note — it was live in the tree for one commit.

xtensa: the row stands, and the REPRO NEEDS A FLAG

frankh-c0 could not corroborate the xtensa row and was right not to claim it was wrong. The invocation is the difference:

./compiler/pascal26 --dce --target=xtensa tiny.c out
  -> rc=1, REFUSED: "a STANDALONE EXECUTABLE on the ESP profile has no argc on
     the stack ... Build a RELOCATABLE OBJECT instead"   (measures nothing)

./compiler/pascal26 --dce --target=xtensa --platform=posix --xtensa-soft-mulhigh tiny.c out
  -> builds; pre-fix SIGSEGV under qemu-xtensa, post-fix prints 42

--platform=posix is load-bearing. The ESP profile refuses a standalone C entry stub by design; the posix profile is what produces an ELF qemu-xtensa can run. My measurement used it and the Makefile leg uses it; the resolution above did not SAY so, and the next person reaches for the short form and gets a refusal that reads like an unrelated bug. Now stated in the recipe too.

The literal-anchor point is why this matters: xtensa is the arm most likely to regress alone, and it is the one whose repro needs a different command line.

What survives unchanged

The pattern in my own two errors, since it is the same one twice

Both were a confident mechanism written for a number I had already measured and not re-read: the riscv32 silence was in my first repro output, and the xtensa flag was in my own command line. Neither needed new work to catch — only re-reading the terminal before explaining it. A correction is an assertion like any other and wants the same evidence as the thing it corrects; mine had none beyond plausibility, and plausibility is exactly what a wrong mechanism has.

AND "FOUR TARGETS, ONE CAUSE" IS TOO TIDY — xtensa IS A DIFFERENT EXPERIMENT

Line 180 above says "four targets, one cause, one symptom". The cause is one and the phrasing is still wrong, because it makes four rows look like four draws from one population. They are not:

So the xtensa row proves the literal-anchor form was broken and is fixed, which is the encoding the other three never reach. It proves nothing about C on the ESP profile, which does not take this road.

Why the tidy phrasing is a hazard rather than a simplification (frankh-c0's point, and it is the reason this section exists): "four targets, one cause" invites the next reader to verify three and assume the fourth — and the fourth is the one with a different command line, a different profile and a different encoding, i.e. the one most likely to regress alone and the one least likely to be caught doing it. Recorded in the recipe as two experiments rather than as four-of-a-kind.

THE READING FAILURE WAS SYMMETRIC, AND THAT IS THE FINDING

Both seats had the SAME three-line result in their own scrollback:

aarch64  rc=139  out='qemu: uncaught target signal 11 ...'
arm32    rc=139  out='qemu: uncaught target signal 11 ...'
riscv32  rc=139  out=''

One read the rc column, saw 139 uniformly, and concluded the reported exit 0 must be an rc confusion. The other read the output column, saw the silence, and supplied an rc to match it. Neither was missing data. Each compressed a two-column result into the column they were already thinking in, and the two wrong claims are mirror images drawn from one measurement.

That is worth more than either correction: the tell was not available to more care, it was available to reading the row instead of a column. Where a result has more than one channel — status and output, size and content, count and population — name what every channel says before explaining any of them.

The section below was written by frankh-c0 before the one above landed. They overlap on the riscv32 row and agree. Where it says the xtensa row is UNCONFIRMED from this checkout, the section above settles it: the measurement used --platform=posix, not --emit-obj, and the recipe now says so.

2026-09-22 (frankh-c0) — the riscv32 exit 0 row in the matrix above was MINE and it was wrong

Recorded here rather than silently corrected, because the row was quoted twice and the reason it was wrong is not the reason first offered for it.

Measured by mutation: compiler/symtab.inc reverted to a79934842~1, rebuilt, run, restored, rebuilt (binary back to f7dedaea694f, tree identical to HEAD). Pre-fix, the seven-line C hello world:

target program rc stdout/stderr
aarch64 139 qemu: uncaught target signal 11 (Segmentation fault) - core dumped
arm32 139 same
riscv32 139 (nothing at all)

So there was never a silent-exit-0 arm. What is real is that riscv32's runner prints no crash banner, where the other two announce themselves unmissably — and I turned "no diagnostic" into "exit 0" without ever reading the status I was claiming.

The explanation offered at resolution time — that I read the compiler's rc for the program's — was tested and does not hold: in the && chain this ticket publishes, and in the bare form, a crashing riscv32 program reports 139 either way. The error was inferring an exit status from a SILENCE, on the one target whose runner is silent. Same family as reading a wrapper's status for a job's, one step earlier: there was no wrong number to read, so I supplied one.

This strengthens the fixture rather than weakening it. test_dce_c_cross_entry.c asserts stdout on every leg. On riscv32 the qemu banner is not available as a tell at all, so stdout is not merely the better assertion — it is the only one that separates a pass from a crash.

And one row of the resolution's own corrections is unconfirmed from here: --dce --target=xtensa on a standalone executable is REFUSED by design, at HEAD and pre-fix alike ("a STANDALONE EXECUTABLE on the ESP profile has no argc on the stack ... Build a RELOCATABLE OBJECT instead"). If the xtensa measurement went through --emit-obj, the ticket should say so: the next reader will reach for --target=xtensa as I did and get a refusal that looks like a different bug.