← board

A field past the first eight bytes of an indirect call's struct result reads back as offset zero

Found while adding riscv32/xtensa coverage for [[bug-a-riscv32-and-xtensa-still-refuse-aggregate-results-via-virtual-and-indirect-calls-under-a-done-ticket]], by writing the C-frontend probe that ticket's marshalling change called for. The probe disagreed with gcc on x86-64, which that change does not touch.

int printf(const char *, ...);
struct P { int x, y, z; };
struct P mk(int s){ struct P p; p.x=7; p.y=11; p.z=13; return p; }
int main(void){ struct P (*fp)(int)=mk; struct P v=fp(3);
  printf("%d %d %d\n", v.x, v.y, v.z); return 0; }
gcc     7 11 13
pxx     7 11 7      <- v.z reads back as v.x

rc=0 either way. Nothing crashes, nothing warns.

What it is NOT, each checked

variation result
plain local struct, no call at all correct
struct from a DIRECT call correct
four-field struct, plain local correct
fields copied to int locals, then printed correct
non-variadic callee instead of printf correct for a local struct
8 / 12 / 16 / 24-byte structs through a pointer, each printed in its own statement correct

So it is not the struct return, not the struct layout, not variadics, and not the field offsets — int a=v.x, b=v.y, c=v.z; prints 7 11 13 from the very same object. The bytes are right; the read in argument position is wrong.

The two conditions that must BOTH hold are: the struct came from an indirect call, and the field is read directly as a call argument. Break either and it is correct. That pairing is why the size sweep above came back clean — those rows printed each struct in its own statement.

The shape of the cause, not yet the cause

A destination temp is the obvious suspect: an indirect call's result needs a caller-owned destination, and the failing read is the one that happens while a NEW call's argument block is being built. v.z coming back as v.x is an offset that lost its +8, or a base that was recomputed to the wrong object — both are what a temp whose address is re-derived at the wrong moment looks like.

Do not take that as the diagnosis. It is the hypothesis to disprove first, and PXXDBG=a.ir:main on the four-line repro is cheaper than reading the emitter — that is what it is for.

Bound

Reproduces identically on the v399 pin and at HEAD 174a53388c9b, so it is not a regression from anything landed today. Not measured on the other backends: the probe reaches them only through the same source, and i386 and riscv32 both MATCHED gcc on the larger version of it, which is worth knowing but is not the same claim as a clean run of THIS repro.

Why prio 60

A silent wrong value with rc=0 in a construct C code uses freely — a function pointer returning a struct is ordinary in callback and vtable-shaped C, which is exactly what the busybox and crtl work is compiling. Nothing in the tier chain can see it: it does not crash, does not warn, and the field it corrupts is the third one, so a two-field struct passes.

Resolution

Cause: CParseFnSigGroup (cparser.inc) never recorded the record id of a struct returned BY VALUE. It already recorded the pointed-at record for a struct-POINTER return (struct S *(*)(), so p()->field resolves), and a NAMED C function records the by-value case at the bottom of the declaration parser (if retType = tyRecord then ProcRetRecId[procIdx] := retRecId) — which is exactly why a DIRECT call was always correct and only the function pointer was wrong. ResolveNodeRec reads ProcRetRecId[ASTIVal[node]] for every call kind, an indirect call carries the fn-pointer signature's Procs index there, and an unset field answers REC_NONE, which applies the member at offset 0.

The fix gives CParseFnSigGroup a fourth parameter carrying the declarator's own baseRec, captured beside the existing CTypeElemTk/CTypeElemRec capture before the recursion, and sets ProcRetRecId[fpSig] when the return is tyRecord. Both call sites pass it, including the function-TYPED parameter site (static int viaparam(struct P3 cb(int))), which busybox writes without a typedef.

The hypothesis this ticket recorded was wrong, and the section above says so about itself: this is not a temp address re-derived at the wrong moment, it is a record id that was never written. PXXDBG=a.ir:main was indeed the cheap instrument.

Ablation. The first probe I wrote appeared to pass at HEAD and nearly closed this as already-fixed — the spelling masked it, and the pin had moved v399→v403 underneath. Stash the fix, rebuild, re-run, pop, rebuild, cmp: without the change the ticket's own symptom local 7 11 7 is live at HEAD.

Test. test/c_fnptr_struct_result_fields.c, 11 rows, wired in test-core against a gcc -O0 oracle at BOTH widths. Rows 2/3/4/6 are the spellings that were always correct, kept deliberately: REC_NONE is also what a too-eager fix produces, and a test that only checked the broken spelling would pass while direct calls regressed. Green on x86-64, i386 and riscv32.

aarch64 and arm32 are excluded and it is not this bug. They segfault on any indirect aggregate return, before and after this change, from one byte up — [[bug-a-the-cdecl-indirect-call-arm-never-sets-up-the-hidden-aggregate-result-register-on-aarch64-and-arm32]], filed with a pure-Pascal reproducer that shows the discriminator is cdecl, not the language.

Log