Files
sx/issues/0079-global-array-element-store-dropped.md
agra 7306d37748 fix(ir): store to module-global array element targets live storage (issue 0079)
A store to a module-global array element (`g[i] = v`) was silently dropped:
a subsequent `g[i]` read the array's initializer, not `v`. Constant index,
variable index, and cross-function stores were all affected, in both `sx run`
and `sx build`. Global scalars and local arrays were fine.

Root cause: `Lowering.lowerExprAsPtr` (the lvalue/address path) handled only
local identifiers. A module-global identifier fell through to the value
fallback `lowerExpr`, which emits `global_get` — loading the whole array by
value. The LLVM backend's `emitIndexGep` then allocas a throwaway temp, copies
the value in, and GEPs into the temp, so the store wrote a discarded copy.

Fix: teach `lowerExprAsPtr`'s identifier arm about globals — emit `global_addr`
(a pointer into the global's live storage), or `global_get` for a pointer-typed
global (mirroring the local pointer case). Route the `address_of(index_expr)`
array base through `lowerExprAsPtr` too so `&g[i]` is likewise an lvalue into
the global. `index_gep` now GEPs directly into the global for const and variable
index, across functions. This also fixes global struct field stores, which
shared the same root cause.

Regression: examples/0136-types-global-array-element-store.sx (const-index,
var-index, cross-function store on a scalar global array; struct-element array
for stride; nested-array global for the recursive lvalue). Fails on the pre-fix
compiler, passes after.
2026-06-04 03:44:19 +03:00

4.9 KiB

0079 — stores to module-global array elements are silently dropped

RESOLVED. Root cause: Lowering.lowerExprAsPtr (src/ir/lower.zig) — the lvalue/ address path — handled only local identifiers (alloca pointers). A module-global identifier fell through to the value fallback lowerExpr, which emits global_get (loads the whole array by value). The LLVM backend's emitIndexGep then sees an array value, allocas a throwaway temp, copies the value in, and GEPs into the temp — so g[i] = v wrote a discarded copy and a later g[i] read the global's untouched initializer. Local arrays worked because they hit the alloca-pointer path; global scalar stores worked via global_set. Fix: teach lowerExprAsPtr's identifier arm about globals — emit global_addr (a pointer into the global's live storage) for a normal global, or global_get for a pointer-typed global (mirroring the local pointer case). The same array-base resolution in the address_of(index_expr) path now routes through lowerExprAsPtr so &g[i] is also an lvalue into the global. index_gep then GEPs directly into @g for const AND variable index, across functions, in both sx run and sx build. Regression: examples/0136-types-global-array-element-store.sx (const-index, var-index, cross-function store on a scalar global array; a struct-element global array for element-stride; a nested-array global for the recursive indexed lvalue). FAILS on the pre-fix compiler (reads return the initializer / zero), PASSES after.

Symptom

A store to a module-global (file-scope) ARRAY element is silently lost: after g[i] = v, reading g[i] returns the array's INITIALIZER value, not v. No diagnostic. Reproduces with a constant index, a variable index, and a store from another function, in BOTH sx run (JIT) and sx build (AOT). Local-array stores and global-SCALAR stores work correctly — so the indexed load/store on a global array appears to read/write the initializer constant rather than the global's live storage. This is a SILENT data-corruption bug (the dangerous class per the project rules), not a crash.

Manager-reproduced output (JIT):

global[1] const-idx=20      (want 222)
global[k] var-idx=30        (want 333)
global[0] via fn=10         (want 111)

Reproduction

#import "modules/std.sx";

g : [3]s64 = .[10, 20, 30];
write_global :: (i: s64, v: s64) { g[i] = v; }

main :: () {
    loc : [3]s64 = .[10, 20, 30];
    loc[1] = 222;
    print("local[1]={}\n", loc[1]);          // 222  (correct)
    g[1] = 222;
    print("global[1] const-idx={}\n", g[1]);  // 20   (WRONG, want 222)
    k := 2;
    g[k] = 333;
    print("global[k] var-idx={}\n", g[k]);     // 30   (WRONG, want 333)
    write_global(0, 111);
    print("global[0] via fn={}\n", g[0]);       // 10   (WRONG, want 111)
}

./zig-out/bin/sx run <file> → prints the WRONG values above. Expected: local[1]=222 / global[1]=222 / global[k]=333 / global[0]=111.

Investigation prompt

A store to a module-global array element (g[i] = v) is silently lost: a subsequent g[i] yields the initializer value, in BOTH the JIT (sx run) and compiled (sx build) paths. Global SCALAR stores and LOCAL array stores both work, so the defect is specific to INDEXED lvalue access on a GLOBAL array. Suspect the IR-gen / codegen for an indexed expression on a global symbol: the load of global_array[idx] is likely decaying / constant-folding to the array's initializer constant (or the address computation for the store targets a private copy of the initializer rather than the global's live storage). Look in the index-expression lowering and global-symbol address resolution (and how global array initializers are materialized) — src/ir/lower.zig / src/ir/emit_llvm.zig and the global-symbol/constant-initializer paths.

The fix must make load(global_array[idx]) read the global's live storage and store(global_array[idx], v) write it — for constant AND variable indices, and when the store happens in a different function than the read. Do NOT special-case; fix the address resolution so a global array element is an lvalue into the global's storage like any other.

Verification (once fixed)

  • The repro above prints 222 / 333 / 111 for the global cases (exit 0).
  • Add a pinned regression examples/NNNN-*.sx covering const-index, var-index, and cross-function store to a global array (+ a global array of a struct/larger element if practical).
  • zig build && zig build test && bash tests/run_examples.sh all green.

Provenance

Discovered by the distribution flow (sx-foundation step F3.1, std.cli argv accessor) while exploring argv backing strategies. The shipped F3.1 code does NOT depend on this (it uses caller-provided buffers + zero-copy views over the C argv block), so F3.1 is correct independently — this is a separate latent silent-miscompile surfaced per the STOP-on-compiler-bug rule.