← board

Self-host miscompilation: 3-function program with if/else if gives wrong result

Summary

compiler/pascal26 built via the normal 3-stage make bootstrap (FPC → PXX → PXX, byte-identical) gives a different, wrong runtime result than the exact same source built directly by FPC, for the same input program. The self-host fixedpoint (make bootstrap's byte-identical check) does not catch this — it only proves PXX-built-by-PXX is internally consistent with itself, not that it matches FPC-built behavior. This is a real, silent correctness bug: no crash, no error, just a wrong number.

Minimal repro

fn f1(a: i32) -> i32 {
    return a;
}
fn classify(n: i32) -> i32 {
    if n == 0 {
        return 10;
    } else if n == 1 {
        return 20;
    } else {
        return 30;
    }
}
fn main() -> i32 {
    let mut total = 0;
    total = total + classify(1);
    return total;
}

(.rs extension via the Track R frontend, but see "Is this Rust-specific?" below.) Expected exit code 20.

What's been ruled out

Is this Rust-frontend-specific?

Structurally it looks like a general call-fixup / codegen interaction bug that any frontend generating this AST shape (3 procs, one with a return-per-branch if/else-if chain, forward/plain calls between them) could hit — it reproduces with zero Rust-only IR nodes (AN_IF/AN_CALL/ AN_ASSIGN/AN_EXIT, all shared). It just hasn't been caught before, either because no existing Pascal/C/Nil-Python test happens to match this exact shape, or because it's specifically about how rparser.inc's own source (new Pascal code) gets self-compiled — not yet isolated further; a Track A investigation with -g/disassembly on the self-hosted compiler compiling itself around ApplyCallFixups/EmitProcPrologue/CompileAST's AN_IF path is the natural next step, not something this ticket's author attempted (would mean touching shared symtab.inc/ir.inc/ir_codegen.inc concurrently with other Track A work already landing on master).

Impact / how Track R is proceeding around it

Not blocking forward progress: Track R (Rust frontend, ~/frank2 branch feature/rust-frontend-skeleton) validates sub-tickets against the FPC-built compiler as the correctness oracle (confirmed correct on every test in sub-tickets 1-2) and only uses the self-hosted make bootstrap binary for the byte-identical-fixedpoint check, not as a correctness reference, until this is fixed. Flagging this explicitly: sub-ticket 1/2's "self-compiles to correct runtime output" claims are true for the FPC-built compiler; the self-hosted compiler currently silently gives wrong output for at least this input shape, discovered fortuitously by Rust test programs but not caused by the Rust frontend's AST/IR usage (all shared node kinds, already used elsewhere).

Acceptance

ROOT CAUSE FOUND — 2026-07-04 (Track A investigation)

Reproduced read-only from ~/frank2 source built to /tmp (FPC-built frank2 → 20 correct; PXX-built/self-hosted → 1 wrong; ticket said 2 — same class, wrong either way). Then narrowed with the -S disassembler + --dump-ir:

1. NOT the general if/else-if shape (ticket's frontend-agnostic hypothesis REFUTED). The identical repro shape written in Pascal (exit(n) per branch) AND in C (int main(){...return total;}, f1 uncalled, classify called) compiles correctly and byte-identically under both FPC-built and PXX-built pascal26. So the shared if/else-if / call / return codegen is fine. The trigger is specific to the Rust frontend's IR shape.

2. The trigger: the Rust frontend mis-lowers else if. --dump-ir of classify (via frank2) shows the first if's else-branch lowered to an IR_UNSUPPORTED node (sitting between the else-label and the merge-label), and the else if chain emitted after the merge label instead of nested inside the else. So classify(1): n==0? no → jump to the else-label → lands on the IR_UNSUPPORTED node.

3. The divergence: IR_UNSUPPORTED is handled nondeterministically across self-host. IR_UNSUPPORTED has NO codegen case in ir_codegen.inc (neither master nor frank2). As an unreferenced value node it should never be emitted:

Fix ownership (two distinct fixes)

Log

CLOSED — 2026-07-04 (Track A finished the ownerless half)

Both halves are now on master and verified together:

  1. Track R trigger (already merged): rparser.inc:609 reads elseNode := RParseIf() — parens present, with the own-name-Result-pseudo-var landmine documented inline. Confirmed on master, not just frank2.
  2. Track A hardening (landed 2026-07-04, commit 77e2fbd7): the opt-in --strict-ir guard — IRVerify hard-errors on any IR_UNSUPPORTED node (referenced OR dead), turning "frontend gap → silent nondeterministic self-host miscompile" into an immediate compile error naming the AST kind. This is the general safety net that would have caught the dead node the instant rparser emitted it. Default OFF (Track R rust frontend still in dev); flip-to-default tracked in [[feature-selfhost-guard-ir-unsupported]].

Regression gate (the acceptance item): test/test_rust_else_if.rs — the exact ticket repro (3 fns, one if/else-if/else-return chain, a call) — wired into make test (test-core), asserted to exit 20 both plain and under --strict-ir (the latter also proves rparser no longer emits IR_UNSUPPORTED here). This is the repo's first rust-frontend gate in make test — master carried the rust frontend with zero make-test coverage until now. Verified on the self-hosted pascal26: exit 20 (was 1/2 wrong before the rparser fix).

The ticket's "is this Rust-specific?" thread resolved to YES (Track A's 2026-07-04 investigation: the identical shape in Pascal and C compiled correctly and byte-identically) — so a .pas/.c regression can't reproduce it; the .rs gate is the right and only faithful regression.