← board

A struct assignment used as a value runs its RHS twice

Found by the csmith differential campaign ([[feature-c-csmith-differential-fuzzing]]), seed 90202, 2026-08-15. Filed and fixed in the same sitting; kept as its own ticket because the mechanism is worth writing down.

Repro

struct S { int a; int b; };
static int calls = 0;
static struct S f(void) { calls++; struct S s; s.a = 1; s.b = 2; return s; }

int main(void) {
  struct S x, y, z;
  calls = 0; y = (x = f());      /* gcc: 1    pxx: 2 */
  calls = 0; z = y = (x = f());  /* gcc: 1    pxx: 3 */
}

j = (i = g()) with an int was always right — the scalar arms already had the rule.

Mechanism

IRLowerAST's AN_ASSIGN arm ends the scalar paths with an explicit re-read:

Result := IRAppend(IR_STORE_SYM, ...);
{ In C an assignment is an expression yielding the stored value, so load it
  back; this lets `g(p = x)` / `(p = x)->f` use the value. }
if CProgramMode then
  Result := IRAppend(IR_LOAD_SYM, ...);

The RECORD arms never got the aggregate half of that rule and returned the IR_COPY_REC node itself. So y = (x = f()) lowered to

4: call
5: copy_rec  dest=x  src=4
6: copy_rec  dest=y  src=5     <- the inner COPY_REC as an operand

and node 5 is BOTH a statement in the flat IR list AND an operand of node 6. The emitter walks it at top level, and then walks it again as node 6's source — re-emitting its whole subtree, call included.

The values were still right (the second call recomputes the same struct), which is exactly why lua, sqlite, tcc, zlib and c-testsuite all missed it: a side-effect-free RHS is indistinguishable. csmith's oracle is a checksum over globals, and the doubled call mutated one.

Fix

One place, mirroring the scalar comment: at the end of the AN_ASSIGN arm, in C mode only, when the result is an IR_COPY_REC/IR_COPY_REC_MANAGED, the node's value becomes IRLowerAddress(ASTLeft[node]) — a record's "value" in this IR is carried by address, so the re-read of the destination IS its address. Pascal keeps the store node as its result, so the self-host build stays byte-identical.

Fixing it per-arm was the alternative and was rejected: there are seven IR_COPY_REC result sites in that arm (whole record, managed record, static array, dyn-array row, array-from-call, ...) and the one that gets missed is the one that stays broken.

Verified

Log