← board

Halt(n) exits 0 on hosted riscv32

Found 2026-08-21 while building the verification path for [[feature-a-riscv64-as-a-hosted-first-class-target]] — Halt(n) was going to be how the first riscv64 increment proved itself end to end, so it got checked first.

Measured

program min; begin Halt(7); end.
target exit
x86-64 7
i386 7
arm32 7
aarch64 7
riscv32 (hosted) 0
FPC (oracle) 7

Same answer with a computed code (code := 5; Halt(code)), and the WriteLn before it printed normally — so the program ran to completion and then reported success.

Cause

ir_codegen_riscv32.inc, the IR_TERMINATE arm:

if IRIVal[node] = AN_HALT then
  EmitExit(0)                            { bare-metal: park in a self-loop }

IRA[node] — the halt code — is never read. The comment is the tell: it describes ESP bare metal, and it was applied to the whole arch, hosted profile included. riscv32 is dual-role, and this is the same mis-keying as [[bug-a-real-is-single-on-hosted-riscv32]] and the emit.inc / exception_emit.inc arms tabulated in the riscv64 ticket: TARGET_RISCV32 used as a proxy for "small, bare, no OS", which stopped being true when the target became dual-role.

EmitExit itself was already correct for hosted riscv32 (a7 = 94, a0 = code, ecall) — but its encodings only take a CONSTANT, and nothing ever passed it one but zero.

Fix

Mirror the aarch64 arm, which does it right: evaluate the code expression into the first argument register, then load the syscall number, so computing the code cannot clobber a0.

if (IRA[node] <> -1) and (not EspBareBoot) then
begin
  IREmitNodeRISCV32(IRA[node]);        { a0 = the exit code }
  EmitI32($05E00893);                  { addi a7, x0, 94  (exit_group) }
  EmitI32($00000073);                  { ecall }
end
else
  EmitExit(0);                         { no argument, or ESP bare: park }

ESP bare keeps the self-loop, which is what the old comment actually described — now it is guarded by EspBareBoot rather than by the arch.

Why it is worth a prio-45 and not a footnote

A lost exit code is the shape of wrong answer this repo pays most for: nothing crashes, nothing prints, and every caller that branches on $? — a shell script, a CI job, a Makefile test "$(...)" = ... assertion — silently takes the wrong branch. It also means any riscv32 test that signalled failure by exiting nonzero has been passing vacuously.

Test

test/test_halt_exit_code.pas, on all five hosted targets (Makefile rows next to each test_signal_sp_rewrite row). It halts with a computed code after writing two lines: a constant could be satisfied by a smarter EmitExit, but a variable only passes if the expression really reaches the argument register, and the writes catch an exit taken too early. Verified against FPC, which prints the same two lines and exits 5. All five targets and the oracle now agree.

Gate

make compiler/pascal26 (byte-identical fixedpoint) + the five-target differential against FPC + tools/gate.sh quick. Cross-target breadth is Track T's, against this sha.