An interrupt; proc reached through a normal call returns via mret, silently
The mechanism
Measured 2026-09-21 at pin v414 by disassembling test/test_esp_interrupt.pas
compiled --emit-obj on both ESP ISAs. An interrupt; body is a correct raw
trap routine:
| riscv32 | xtensa Call0 | |
|---|---|---|
| prologue saves | t0-t6, a0-a7 (64 B) + ra/s0 |
a2-a13 (48 B) + a0/a15 |
| returns via | mret (30200073) |
rfe (003000) |
That is exactly right for a hardware vector entry and exactly wrong for
anything reached by jalr/callx. mret/rfe pop privilege state and jump
through the machine exception PC — in a normal call there is no trap frame to
return through, and the return address the caller left is never consulted.
Why nothing catches it
- The compiler does not care how a proc's address is used.
IR_PROCADDRmaterialises the body address for any routine; nothing consultsProcIsInterruptat the@procsite. - The linker cannot care. Both directives produce a
FUNCsymbol in.iram1.text; the reloc is identical. - The IDF cannot care.
esp_intr_alloc(source, flags, handler, arg, ret)takes avoid(*)(void*)— any code address satisfies it.
So the failure surfaces only as a fault on the device, at a point unrelated to the declaration, which is the most expensive place this fleet can be wrong.
Why this is a bug rather than documentation
The two existing call sites are both correct, and that is the whole hazard: the
rule is enforced by coincidence. CLAUDE.md's normalise-dont-special-case
sibling clause is exactly this shape — "both spellings mean the same thing to
the person who wrote the source, so neither the construct name nor the test
corpus distinguishes them" — and its prescription is to reach for the other
spelling's handler rather than the feature. Here the cheaper discharge is a
refusal at the one site that can see both facts.
Suggested shape (not prescribed)
Refuse, or at minimum warn, when @<proc> of a routine with ProcIsInterrupt
set is used as anything other than the operand of a raw vector install. Today
that is trivially decidable because there is no raw vector install at all
— see the CSR ticket below — so every @interrupt_proc is currently a
mistake. That makes the positive control easy and it makes the refusal cheap
now and refinable later.
Positive control, and it must be drawn from the right population:
test_esp_isr_register.pas with its iram; changed to interrupt; must be
REFUSED, and the file as it stands must still compile. Assert both arms — a
refusal that also rejects the correct spelling is not a guard, it is a break.
Related
devdocs/dev/esp32-hardening-map.md§1.2 — the measurement and the buckets.feature-s-a-csr-write-is-not-expressible-from-pascal-so-no-raw-isr-can-be-installed— why the raw arm is currently unreachable, which is what makes the refusal cheap today.
Log
-
2026-09-21 — resolved, commit d305e1afa. Refused at the AN_PROCADDR convergence point in ir.inc, so every spelling of
@fis covered rather than the one the ticket happened to name. BOTH CONTROL ARMS ASSERTED: test_esp_isr_register.pas as it stands (iram;) still compiles and produces an object; the same file withiram;changed tointerrupt;is REFUSED at line 30 — exactly theh := @MyIsr;line — and produces none. The refusal is unconditional today because no raw vector install exists in the language; the code comment says in terms that it must be NARROWED to permit the install operand when that lands, not deleted.gate.sh quickGREEN, read from the job's own verdict line rather than the wrapper's exit status; fixedpoint a7ce4183f209. RESIDUAL, SPLIT OUT RATHER THAN CLOSED SILENTLY: a DIRECT CALL to aninterrupt;routine is the same fault by a different spelling and is deliberately NOT refused, because test_esp_interrupt.pas depends on one to force body emission. Filed asbug-s-a-direct-call-to-an-interrupt-routine-is-the-same-trap-return-fault-by-another-spelling. -
2026-09-21 — INERT UNTIL A PIN CARRIES IT, and the resolution above did not say so.
d305e1afais a compiler change, so every$(PXX_STABLE)consumer is still getting the PRE-refusal compiler: a lane that builds against the pin can still write@<an interrupt; routine>, hand it toesp_intr_allocand get the silent trap-return this ticket exists to refuse. Verified not carried by pin v414 (aeadb1754b80), which is the current pin. CLAUDE.md requires this line — "before closing a compiler fix that alib/**file depends on, check whether a pin carries it and say so in the resolution" — and I closed without it; flagged by frankz-e5. Not a reason to hold anything: never wait for a pin. What retires this note is the next pin, whatever its number. -
2026-09-21, later — RETIRED. PIN v415 CARRIES IT AND THE GUARD FIRES. Verified here rather than relayed, and in both directions, because ancestry says the SOURCE is in while only running it says the GUARD FIRES:
- pin commit
8218ddf28subject saysbinary sha256 94fddf62ee6a, andsha256sumof the pinned binary on disk gives94fddf62ee6af731...— the identity a wrong population cannot imitate, rather than agit log --grep 'pin v415', which returns prose about the pin. d305e1afais an ancestor of the pinned SOURCE commit0176aa3ce, not merely oforigin/master. Different claims; only the first means the pinned compiler contains the fix.- Both control arms re-run against the PINNED binary, not HEAD:
test_esp_isr_register.pasas it stands (iram;) compiles and produces an object; the same file withiram;→interrupt;is REFUSED at line 30 and produces none.
Retired the moment its condition was met, which is the point. The note named "the next pin, whatever its number" as its retiring event, and a retirement condition nobody re-reads once satisfied is the failure documented the same day in
devdocs/dev/esp32-hardening-map.md§2.2 — where a row asked for a census that had landed fifty lines below it, in the same commit. Such a row is checked by readers who are checking exactly the right thing and who still get the wrong answer. - pin commit