kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

commit 14bd1c60e822fe089b54af53117d9f501ec91f03
parent bf084c97cb134114880b7f0c936697e14cfb33da
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Tue, 16 Jun 2026 10:13:03 -0700

cg: a volatile local load must emit a real observable memory access

kit_cg_load served a volatile load of a register-resident scalar local
(RES_FIXED_LOCAL) from the local's value residency, handing back the
register value with NO memory access. The volatile boundary clears the
const shadow, but the value-residency fast path was not gated on the
observable flag, so a volatile load whose result is unused emitted no
IR load at all -- leaving x=40; binop x+2 with the constant still live.

This was latent (the CG has produced the load-less shape for the
volatile-shadow shape for some time) and only became a code-quality test
failure once the O1 same-block const tracker (O1-PATTERNS L9) gained the
ability to fold the resulting 40+2 across a single block: test/api/
cg_type_test.c:1298 then saw the volatile function fold to movz w0,#42
identically to the fully-folded baseline.

Fix at the root: for a volatile (observable) load of a source-local,
force the local to a real memory home via api_lvalue_addr (exactly as
address-taking already does) and load through an OPK_INDIRECT place. The
load is then a genuine observable memory access (mem flags VOLATILE),
which DCE/promote_scalar_locals correctly refuse to eliminate. Non-
volatile local folding is unchanged.

Fixes test/api/cg_type_test.c:1298 (cg_api_test 289/289).

Diffstat:
Msrc/cg/memory.c | 19+++++++++++++++++++
1 file changed, 19 insertions(+), 0 deletions(-)

diff --git a/src/cg/memory.c b/src/cg/memory.c @@ -342,6 +342,25 @@ void kit_cg_load(KitCg* g, KitCgMemAccess access) { return; } + /* A volatile load is an externally observable access: it must emit a real + * memory read even when its result is unused, and it must never be served + * from the local's register residency or its const shadow. The shadow was + * already cleared by the boundary above (so api_local_const_load declines), + * but a register-resident scalar local (RES_FIXED_LOCAL) would otherwise be + * handed back below with no memory access at all -- the optimizer then DCEs + * the would-be load and folds the constant through it. Force the local to a + * real memory home (materializing a frame slot, exactly as address-taking + * does) and load through it so the access survives. */ + if (base.source_local != KIT_CG_LOCAL_NONE && + (access.flags & KIT_CG_MEM_VOLATILE) && base.op.kind == OPK_LOCAL) { + KitCgTypeId pty = cg_type_ptr_to(g->c, base_ty); + Operand addr = api_lvalue_addr(g, &base, pty); + /* api_lvalue_addr does not consume the place (it forces the home and + * returns its address); retarget base to load through that home. The + * general load path below issues the single api_release(&base). */ + base = api_make_lv(api_op_indirect(addr.v.local, 0, base_ty), base_ty); + } + /* Source-local constant load. */ if (!is_bitfield && base.source_local != KIT_CG_LOCAL_NONE && api_local_const_load(g, base.source_local, access, &dst)) {