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.
350 lines
14 KiB
C
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;
|
|
}
|