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.
This commit is contained in:
2026-05-16 09:38:30 +09:00
parent 1bf53c2184
commit 1292f98c91
8 changed files with 764 additions and 193 deletions

View File

@@ -880,11 +880,18 @@ struct Local {
Local *next;
};
/* localoff — push a fresh stack slot for this binding and return its
* BP offset. Never dedups by name (post-#27): two `let a: T` in disjoint
* scopes within one fn must each get their own slot, sized to their own
* declared T. Pre-fix the dedup loop returned the first-allocated slot
* regardless of the new declaration's size, so an outer `let a: [128]u8`
* after an inner `let a: i64` would collapse onto the 8B slot and
* `a[127]` would land at +119(BP), past the saved RIP, into the
* caller's frame. localfind walks from the head, so the most recent
* binding still wins lookups inside its scope. */
static int
localoff(Cg *c, Local **head, const char *name, int size, int *frame)
{
for (Local *l = *head; l; l = l->next)
if (strcmp(l->name, name) == 0) return l->off;
int al = 8;
*frame = (*frame + size + al - 1) & ~(al - 1);
int off = -*frame;
@@ -896,12 +903,11 @@ localoff(Cg *c, Local **head, const char *name, int size, int *frame)
return off;
}
/* local_alloc — always push a fresh slot, never dedup by name. Used for
* match-arm bindings, where `case let e: T` must shadow any outer `e`
* with a slot sized to T — localoff's dedup would reuse the outer's
* (possibly smaller) slot and let multi-word writes overflow into the
* saved BP / return address. localfind walks from the head, so the
* fresh entry still wins inside the arm body. */
/* local_alloc — synonym for localoff. Pre-#27 localoff deduped by name
* and local_alloc was the always-fresh escape hatch (match-arm bindings,
* synthetic scratch slots). Post-#27 localoff is also always-fresh, so
* the two are functionally identical; both names are kept so the call
* sites read intentfully (let-decl vs scratch). */
static int
local_alloc(Cg *c, Local **head, const char *name, int size, int *frame)
{
@@ -5723,10 +5729,27 @@ cgstmt(Cg *c, Node *n, Local **locals, int *frame)
{
if (n == NULL) return;
switch (n->kind) {
case N_BLOCK:
case N_BLOCK: {
/* Save/restore the locals head across the block (post-#27).
* Inner-scope `let` bindings prepend to *locals via localoff;
* without this restore, the prepended stubs leak into sibling
* and ancestor scopes, and localfind (head-first) returns the
* inner binding's offset for an identifier that semantically
* belongs to the outer scope. The frame is left grown — slot
* lifetimes don't overlap with later siblings observably (the
* popped stubs' offsets are no longer reachable by name), but
* we don't reclaim the frame bytes; that's the conservative
* choice C compilers make for simple lowering.
*
* cgfn iterates fn->body->list directly to bypass this
* save/restore at the function's outermost block — defers
* (and the implicit-return epilogue) need locals intact. */
Local *saved = *locals;
for (Node *s = n->list; s; s = s->next)
cgstmt(c, s, locals, frame);
*locals = saved;
break;
}
case N_EXPRSTMT:
cgexpr(c, n->lhs, *locals);
break;
@@ -6700,7 +6723,19 @@ cgfn(Cg *c, FILE *out, Node *fn)
if (tp) tp = tp->next;
}
cgstmt(c, fn->body, &locals, &frame);
/* Iterate the fn body's statements directly rather than dispatching
* the outermost N_BLOCK through cgstmt — N_BLOCK now save/restores
* the locals head to scope inner shadows, but the function body is
* not "an inner block": defers (queued during the body) and the
* implicit-return epilogue both call cgexpr after this loop and
* resolve identifiers via localfind, so the body's locals must
* still be in *locals when we get there. */
if (fn->body && fn->body->kind == N_BLOCK) {
for (Node *s = fn->body->list; s; s = s->next)
cgstmt(c, s, &locals, &frame);
} else {
cgstmt(c, fn->body, &locals, &frame);
}
/* implicit return for void functions */
if (c->tail->as != A_RET) {