/* * 709_localoff_scope — cgen localoff/localadd no longer dedups stack * slots by name across disjoint scopes (task #27). * * Pre-fix: cstage `localoff` and wwstage `localadd` both keyed slot * lookup on name alone. Two `let a: T` in disjoint scopes within one * fn collapsed to a single slot, sized by whoever was allocated first. * The smaller of the two then ran with an offset that, when used at * its declared size, walked past the SUBQ'd frame and into the saved * frame zero / saved RIP / caller stack. Silent stack corruption. * * The bug fired in either direction: * - inner small allocated FIRST, outer big allocated SECOND → * outer's writes near the end of its declared size land at * positive BP offsets (past the saved RIP) → SIGSEGV. * - outer big allocated FIRST, inner small allocated SECOND → * inner's full-size store stomps the outer's first bytes. * * worker-19's commit (c9bbfcb) sidestepped one instance in selfhost/ * cmd/w6a/main.ww by renaming an outer `let asm: asm_;` to `s` so the * inner `let a: *u8 = ...;` (in the for-loop) wouldn't share a slot. * That rename can be reverted once this fix lands (sibling cleanup). * * Fix: drop the dedup. Every `let` allocates a fresh slot (cstage * localoff / wwstage localadd). localfind walks head-first, so the * most-recent (innermost) binding still wins lookups inside its * scope. Wwstage scanlocals stops deduping user-let names in lockstep * so the prologue SUBQ matches the emit-time offsets. Synthetic * scratch slots (`@tagscr`, `@retscr`, `@tagbase`) keep the per-fn * dedup via an `@`-prefix carve-out — they're sized identically at * every call site and intended to be shared. * * row | shape | gate * --------------------------+------------------------------------+--------- * inner_first_outer_bigger | 8B inner, then 128B outer; write | exit=99 * | a[127]=99 in outer scope. Pre-fix | * | SEGV; post-fix slot is 128B. | * outer_first_inner_writes | 64B outer first, nested 8B inner | exit=7 * | writes -1; outer's a[0] still the | * | original 7. | * nested_3_deep | same name `x` at three nesting | exit=6 * | depths, returns the sum of values | (1+2+3) * | read at each scope. | * same_name_diff_type | `let a: i32 = 5;` then disjoint | exit=2 * | block `let a: str = "hi";`. Returns| * | a.len from the str scope. | * (removed) | `let a: i32 = 1; let a: i32 = 9;` | -- * | post-#32 the checker rejects this; | * | covered by 712_redecl's | * | neg_let_same_block row. | * defer_shadow | outer `a`, deferred call captures | exit=42 * | &outer-a, inner-block shadow `a`, | * | return outer a. Pins both: cgfn | * | body-bypass (defer's cgexpr after | * | body iteration must still resolve | * | outer `a`) and inner-block | * | save/restore (inner shadow can't | * | leak past `}`). | * forrange_body_shadow | for (let x .. s) iterates [1,2,3], | exit=47 * | body reads x then declares `let | (1+2+3) * | x: i32 = 99;` then reads x again. | +99-3*8 * | First read = iter, second = inner. | =6+47-24 * | Pins that the iter slot's per-iter | * | load (cgforrange cached offset) is | * | unaffected by the body's shadow, | * | AND that the body's pre-shadow | * | reads still find the iter. | * if_body_shadow | outer `a=7`, then `if (..) | exit=7 * | { let a=99; }`, return a. Pins the | * | if-arm's N_BLOCK save/restore pops | * | cleanly so the return reads outer. | * * Per-row exit-code agreement across cstage + wwstage drivers is the * contract this test pins. Asm byte-identity is NOT diffed here: no * wwstage divergence specific to this fix; 995_self_rebuild covers * cross-stage drift broadly. */ #include #include #include #include #include static int runwait(const char *cmd) { int rc = system(cmd); if (rc == -1) return -1; if (WIFEXITED(rc)) return WEXITSTATUS(rc); return -1; } struct row { const char *label; const char *src; int want; }; static const struct row rows[] = { /* 1. Inner allocated first (in for-loop body), outer allocated * second and bigger. Pre-fix: outer dedups onto inner's 8B slot * at -8(BP); a[127] = 99 emits MOVB AX, 119(BP) — past saved * RIP — SEGV. Post-fix: outer gets its own 128B slot, write * lands inside the slot, exit = 99. */ { "inner_first_outer_bigger", "fn main() i32 = {\n" " let i: i32 = 0;\n" " for (i < 3) { let a: i64 = 5i64; i += 1; };\n" " let a: [128]u8 = [0u8...];\n" " a[127] = 99u8;\n" " return a[127]: i32;\n" "};\n", 99 }, /* 2. Outer allocated first (64B), inner inside a nested block * writes an 8B i64. Pre-fix: inner's full 8B store overwrites * outer's bytes 0..7; outer's a[0] read after the inner block * returns the low byte of -1 (= 0xff = 255). Post-fix: inner * gets its own slot, outer's a[0] keeps the value 7 written * before the inner block. */ { "outer_first_inner_writes", "fn main() i32 = {\n" " let a: [64]u8 = [0u8...];\n" " a[0] = 7u8;\n" " {\n" " let a: i64 = 0i64 - 1i64;\n" " if (a == 0i64) { return 99i32; };\n" " };\n" " return a[0]: i32;\n" "};\n", 7 }, /* 3. Same name `x` redeclared at three nesting depths, each with * a distinct value. Inside each scope, x reads its own binding * (head-first localfind). The inner reads happen WHILE the * outer slots are still live, so the test catches both * cross-scope slot collision (corrupt outer) and miss-up-the- * chain lookup (returns wrong inner value). Sum is 1 + 2 + 3 = 6. */ { "nested_3_deep", "fn main() i32 = {\n" " let x: i32 = 1;\n" " let s1: i32 = x;\n" " let s2: i32 = 0;\n" " let s3: i32 = 0;\n" " {\n" " let x: i32 = 2;\n" " s2 = x;\n" " {\n" " let x: i32 = 3;\n" " s3 = x;\n" " };\n" " };\n" " return s1 + s2 + s3;\n" "};\n", 6 }, /* 4. Same name, different types in disjoint scopes. Outer * `let a: i32 = 5;` (8B slot, scalar), inner `let a: str = "hi"` * (16B slot, ptr+len). Pre-fix: inner reuses outer's 8B slot * and the str ptr/len writes land at -8/0(BP) — corrupting * the saved BP. Post-fix: inner gets its own 16B slot. * Returns inner a.len = 2. */ { "same_name_diff_type", "fn main() i32 = {\n" " let a: i32 = 5;\n" " let r: i32 = 0;\n" " {\n" " let a: str = \"hi\";\n" " r = a.len: i32;\n" " };\n" " return r;\n" "};\n", 2 }, /* 5. Same-block re-declaration moved to test/wcc/712_redecl * (`neg_let_same_block`) when #32 made it a build-time error. * The pin's purpose — exercising the localoff fresh-stub path on * a same-name same-block dup — is now an upstream-rejected shape, * so the codegen branch it covered is no longer reachable through * legal source. Row slot kept empty for stability of the * surrounding row numbering. */ /* 6. Defer + inner-block shadow + outer-scope post-defer read. * * `defer touch(&a)` queues the call; at fn-exit the defer's * cgexpr runs (resolving `&a`) BEFORE the return value is * loaded. We expect &a to bind to the OUTER a (the one in * scope at fn-exit), so touch sets outer a to 42 and the * return reads 42. * * Three independent invariants gate this row: * (i) cgfn body-bypass: cgstmt(N_BLOCK) on fn->body would * restore c.locals to params-only before the deferred * cgexpr runs. With the bypass cgfn iterates fn.body.list * directly so c.locals stays populated for the defer's * cgexpr — &a resolves to outer a's slot. * (ii) cgblock save/restore: the inner block's `let a: i32 = 99` * prepends a stub to c.locals. Without restore, the inner * stub leaks past `}` and head-first localfind picks it * up; touch then writes to the inner slot and the return * reads outer a → 7, not 42. * (iii) localadd always-fresh: even with save/restore, if * outer & inner share one slot (pre-#27), touch writing * 42 stomps the (deceased) inner slot which IS the outer * — so this gate alone happens to land at 42 either way. * Combined with (ii), exit=42 means BOTH (ii) and (iii) * hold; either regressing flips the gate. * * touch(&a) returns 0 to keep its own scalar exit out of the * way; only the side effect through *p matters. */ { "defer_shadow", "fn touch(p: *i32) i32 = { *p = 42i32; return 0i32; };\n" "fn main() i32 = {\n" " let a: i32 = 7;\n" " defer touch(&a);\n" " {\n" " let a: i32 = 99;\n" " if (a == 0i32) { return 1i32; };\n" " };\n" " return a;\n" "};\n", 42 }, /* 7. Forrange body shadow. Distinct codegen path from N_FOR * (cstage cgforrange / wwstage cgenstmt cgforrange + cgendecl * scanlocals N_FORRANGE arm). The iter binding `x` is added to * c.locals via localadd before the body is emitted; the body * declares its own `let x: i32 = 7;` partway through. Two * observations per iteration: * - first `sum += x` (pre-shadow): finds iter x via head-first * localfind on c.locals at that point (no inner stub yet). * Sums to 10 + 20 + 30 = 60 across iterations. * - second `sum += x` (post-shadow): finds inner x = 7. * Sums to 7 * 3 = 21 across iterations. * Total: 81. The iter slot's per-iter rewrite is driven by * cgforrange via cached offset, NOT by name lookup, so the * shadow can't hijack the iter-write — but a regression in * scanlocals' N_FORRANGE arm (e.g. forgetting to append a stub) * would still surface here as the pre-shadow read failing to * resolve `x`. */ { "forrange_body_shadow", "fn main() i32 = {\n" " let arr: [3]i32 = [10i32, 20i32, 30i32];\n" " let sum: i32 = 0;\n" " for (let x .. arr) {\n" " sum += x;\n" " let x: i32 = 7;\n" " sum += x;\n" " };\n" " return sum;\n" "};\n", 81 }, /* 8. If-body shadow. The if's body is N_BLOCK and reaches * cgblock via cgstmt — distinct visual path from a bare * `{ ... }` at fn-body level (rows 2/3/4). `touched` stashes * the inner-a value so we confirm the if-arm actually executed * before checking the outer-scope visibility. With cgblock * save/restore: return reads outer a = 7. Without: head-first * localfind picks up the inner stub still on the chain → 99. */ { "if_body_shadow", "fn main() i32 = {\n" " let a: i32 = 7;\n" " let touched: i32 = 0;\n" " if (a > 0i32) {\n" " let a: i32 = 99;\n" " touched = a;\n" " };\n" " if (touched != 99i32) { return 1i32; };\n" " return a;\n" "};\n", 7 }, }; static int run_driver(const char *driver, const struct row *r, int i) { char tmpdir[64], src[128], outbin[128], rmcmd[160], cmd[1024]; snprintf(tmpdir, sizeof tmpdir, "/tmp/wclo_%d_d_%d", getpid(), i); mkdir(tmpdir, 0755); snprintf(src, sizeof src, "%s/wclo_%d_%d.ww", tmpdir, getpid(), i); snprintf(outbin, sizeof outbin, "%s/wclo_%d_%d", tmpdir, getpid(), i); snprintf(rmcmd, sizeof rmcmd, "rm -rf %s", tmpdir); FILE *f = fopen(src, "wb"); if (!f) { runwait(rmcmd); return -1; } fputs(r->src, f); fclose(f); snprintf(cmd, sizeof cmd, "%s build -o %s %s", driver, outbin, src); if (runwait(cmd) != 0) { fprintf(stderr, "row[%s]: build via %s failed\n", r->label, driver); runwait(rmcmd); return -1; } int got = runwait(outbin); runwait(rmcmd); return got; } int main(void) { const char *bin = getenv("BIN"); if (!bin) bin = "out/bin"; char absbin[512]; if (bin[0] != '/') { char cwd[256]; if (getcwd(cwd, sizeof cwd) == NULL) return 1; snprintf(absbin, sizeof absbin, "%s/%s", cwd, bin); bin = absbin; } char cdrv[640]; snprintf(cdrv, sizeof cdrv, "%s/ww", bin); char wdrv[640]; snprintf(wdrv, sizeof wdrv, "%s/ww_ww", bin); struct { const char *name; const char *path; int gated_on_existence; } drivers[] = { { "cstage", cdrv, 0 }, { "wwstage", wdrv, 1 }, { NULL, NULL, 0 }, }; int n = (int)(sizeof rows / sizeof rows[0]); int total = 0, fail = 0; for (int d = 0; drivers[d].name; d++) { if (drivers[d].gated_on_existence && access(drivers[d].path, X_OK) != 0) { fprintf(stderr, "localoff_scope: skip %s (no %s)\n", drivers[d].name, drivers[d].path); continue; } for (int i = 0; i < n; i++) { int got = run_driver(drivers[d].path, &rows[i], i); total++; if (got != rows[i].want) { fprintf(stderr, "localoff_scope[%s][%s]: exit=%d want=%d\n", drivers[d].name, rows[i].label, got, rows[i].want); fail++; } } } if (fail) { fprintf(stderr, "localoff_scope: %d/%d fixtures failed\n", fail, total); return 1; } printf("localoff_scope: %d/%d ok\n", total, total); return 0; }