Files
ww/test/wcc/709_localoff_scope.c
Hojun-Cho ce3a25a0b4 test: contain sepwork scratch per-driver tmpdir, fix /tmp+in-repo leak (#8)
The wcc test drivers ran `ww build <bare-/tmp src>` with no -o, so the
compiler's <stem>.sepwork scratch landed beside the source and was never
cleaned: unbounded /tmp growth (2195 stale dirs observed) that fills tmpfs
and fabricates phantom test failures + silent harness aborts, and for
in-repo fixture builds leaked .sepwork into the tracked tree.

Each leaking build now writes its source + output inside a per-invocation
tmpdir, passes -o <tmpdir>/<stem> so the .sepwork lands inside it, and
rm -rf's the tmpdir on every exit path -- including fopen-fail and the
expected-fail reject builds (scratch is mkdir'd before the build can fail).
`ww run` and explicit-`-o`/byte-id helpers are left as-is; the 990/993
byte-id comparison logic is byte-for-byte unchanged.

Two items filed separately (this commit holds the no-Makefile / no-main.c
rail):
- #13: a stale <src>.s byte-id readback (749) silently no-ops since
  separate-compile emits .s to <ostem>.sepwork/__root.s; documented inline.
- #14: build-system Makefile recipes build selfhost/cmd/*/main.ww with no
  -o and leak main.sepwork in-tree (bounded, gitignored; own commit).

One concern -- sepwork leak hygiene -- across 228 drivers; uniform
transform applied per-file and two-round reviewed. make test: all 402
passed, zero net-new /tmp scratch, zero test-driven in-repo .sepwork.
2026-06-22 23:29:39 +09:00

350 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. |
* (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 <stdio.h>
#include <stdlib.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 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;
}