Files
ww/test/wcc/709_localoff_scope.c
Hojun-Cho 1292f98c91 cstage+selfhost+test: scope-correct localoff via block save/restore (#27)
localoff (cstage) / localadd (wwstage) deduped stack slots by name
alone, ignoring scope. Outer `let a: [128]u8` and an inner-block
`let a: *u8` shared one 8B slot; prologue truncated to inner size
and outer-scope writes past saved RIP corrupted the frame. Worker-19
hit it during #19 (selfhost/cmd/w6a/main.ww carries a defensive
asm→s rename pointing at this task).

Drop the name-dedup. Each let allocates fresh. Then preserve
outer-scope visibility across inner blocks: cgstmt's N_BLOCK case
saves `*locals` head, walks body, restores. cgfn iterates fn->body
->list directly (bypassing the outermost N_BLOCK) so defers and the
implicit-return epilogue still see fn-body locals after the loop.

Wwstage symmetric: localadd keeps dedup only for `@`-prefixed
synthetic scratches (`@tagscr` / `@retscr` / `@tagbase`) which need
single-slot semantics; user names get fresh stubs. scanlocals always
counts + always appends a fresh stub for N_LET / N_MLET / N_FORRANGE
so prologue SUBQ stays in sync with emit-time offsets. cgblock and
cgfn mirror cstage.

ww2 == ww3 == ww4 byte-identical post-fix.

Test 709 (localoff_scope): 8 rows × 2 drivers = 16 fixtures —
inner_first_outer_bigger, outer_first_inner_writes, nested_3_deep,
same_name_diff_type, same_block_redecl_pin, defer_shadow,
forrange_body_shadow, if_body_shadow. defer_shadow pins the cgfn
body-bypass; if_body_shadow pins the save/restore independently.
Asm byte-id not diffed in 709 — 995_self_rebuild covers cross-stage
drift more broadly.

Follow-ups (filed): #32 (check: refuse same-block let-redecl), w6a
`s`→`asm` revert sibling commit.
2026-05-16 09:38:30 +09:00

365 lines
14 KiB
C

/*
* 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. |
* same_block_redecl_pin | `let a: i32 = 1; let a: i32 = 9;` | exit=9
* | in same block (checker silently |
* | accepts today). Pins last-write- |
* | wins via head-first localfind. |
* 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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>
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. Today's checker silently accepts
* `let a: i32 = 0; let a: i32 = 9;` (scope_define returns NULL
* on dup but the caller in cmd/wcc/check.c:1443 doesn't error;
* wwstage check.ww behaves the same). Pre-fix: both lets shared
* one slot, last write wins by storage. Post-fix: each let gets
* its own slot but localfind walks head-first → still last-
* write-wins observably. This row pins that observable contract
* — if the checker tightens later to reject same-scope redecl
* (task #32), this row is the canary that flips from "exit 9"
* to "build fails", explicitly opting in to the new shape. */
{ "same_block_redecl_pin",
"fn main() i32 = {\n"
" let a: i32 = 1;\n"
" let a: i32 = 9;\n"
" return a;\n"
"};\n",
9 },
/* 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 src[64], tmpdir[64], cmd[1024];
snprintf(src, sizeof src, "/tmp/wclo_%d_%d.ww", getpid(), i);
snprintf(tmpdir, sizeof tmpdir, "/tmp/wclo_%d_d_%d", getpid(), i);
FILE *f = fopen(src, "wb");
if (!f) return -1;
fputs(r->src, f);
fclose(f);
mkdir(tmpdir, 0755);
snprintf(cmd, sizeof cmd, "cd %s && %s build %s",
tmpdir, driver, src);
if (runwait(cmd) != 0) {
fprintf(stderr, "row[%s]: build via %s failed\n",
r->label, driver);
unlink(src); rmdir(tmpdir);
return -1;
}
const char *base = strrchr(src, '/');
base = base ? base + 1 : src;
char outbin[128];
snprintf(outbin, sizeof outbin, "%s/%s", tmpdir, base);
char *dot = strrchr(outbin, '.');
if (dot && strcmp(dot, ".ww") == 0) *dot = '\0';
int got = runwait(outbin);
unlink(src); unlink(outbin); rmdir(tmpdir);
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;
}