From 6ce292b1574a13326d58cc7841af97ee3f6394eb Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Fri, 29 May 2026 03:52:04 +0900 Subject: [PATCH] wwstage: spill non-IDENT scrutinee at cgtypeassert (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cgtypeassert kept scrutoff=0 when the scrutinee wasn't an N_IDENT (direct call result, arr[i], p.field, ?, paren-wrap of any of those), so the tag-load fell on (BP) — the saved-BP word — and the payload- load on +8(BP) — the return address. The wwstage repro returned 220 (garbage from RIP) where cstage returned 42 (impl-e1-resume sibling of #199/#201). Mirror cstage cmd/w6c/cgen.c:6300-6316 N_TYPEASSERT non-IDENT arm. Add an `else` branch after the existing N_IDENT path that resolves the tagged type via matchscrutt, alloc an @asrt_spill slot via matchspillsz/localalloc, cgexpr the LHS, then spill the AX/DX/CX tagged-return-ABI words: AX→+0 (tag), DX→+8 (word0), CX→+16 (word1, guarded on spill > 16). Subsequent tag-check + payload load indexes off the spill like the IDENT path. Helpers reused from cgmatch (cgenexpr.ww:1422-1460). cstage's cgtypeassert omits the cgmatch 4-word R8→+24 spill (rule-10 stage symmetry: rather than diverge into a 32B-payload case the test suite doesn't exercise, mirror cstage exactly and file the cstage omission inline). Filed inline: cstage cgtypeassert needs the same R8→+24 path cgmatch already has (drew's design rationale, blocked by the rule-10 floor today). 772_typeassert_nonident: 7 rows (call_as_size — the repro, call_as_str — CX→+16 spill + BX post-load, call_as_namedvoid — void-variant tag-check fires, payload load is a 0-byte no-op, call_as_fnptr — fn-ptr variant 8B word0, call_as_u8 / call_as_i16 — narrow scalar round-trip via MOVQ + MOVQ confirms no truncation, branched_call_as — runtime-chosen tag). Each row gated on cstage runtime + wwstage runtime + cs.s == ww.s byte-identity. --- Makefile | 7 + selfhost/cmd/w6c/main.combined.ww | 35 +++ selfhost/cmd/wcc/cgenexpr.ww | 35 +++ selfhost/cmd/wwdump/main.combined.ww | 35 +++ test/wcc/772_typeassert_nonident.c | 332 +++++++++++++++++++++++++++ 5 files changed, 444 insertions(+) create mode 100644 test/wcc/772_typeassert_nonident.c diff --git a/Makefile b/Makefile index af136c94..d2d16ee2 100644 --- a/Makefile +++ b/Makefile @@ -322,6 +322,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_dot_aliased_ptr \ $(BIN)/test_return_tagged_forward \ $(BIN)/test_widen_transitive \ + $(BIN)/test_typeassert_nonident \ $(BIN)/test_use_promote_alias \ $(BIN)/test_field_signed $(BIN)/test_frame_argcount \ $(BIN)/test_selfhost $(BIN)/test_w6a_ww $(BIN)/test_w6l_ww \ @@ -660,6 +661,12 @@ $(BIN)/test_widen_transitive: test/wcc/771_widen_transitive.c \ $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +$(BIN)/test_typeassert_nonident: test/wcc/772_typeassert_nonident.c \ + $(BIN)/ww $(BIN)/w6c $(BIN)/w6a $(BIN)/w6l \ + $(BIN)/ww_ww $(BIN)/w6c_ww $(BIN)/w6a_ww $(BIN)/w6l_ww \ + $(LIB)/libwwrt.a | $(BIN) + $(CC) $(CFLAGS) -o $@ $< + $(BIN)/test_arrlit_str_full: test/wcc/711_arrlit_str_full.c \ $(BIN)/ww $(BIN)/w6c $(BIN)/w6a $(BIN)/w6l \ $(BIN)/ww_ww $(BIN)/w6c_ww $(BIN)/w6a_ww $(BIN)/w6l_ww \ diff --git a/selfhost/cmd/w6c/main.combined.ww b/selfhost/cmd/w6c/main.combined.ww index 95165821..c728aa78 100644 --- a/selfhost/cmd/w6c/main.combined.ww +++ b/selfhost/cmd/w6c/main.combined.ww @@ -18010,6 +18010,41 @@ fn cgtypeassert(c: *cgen, n: *node) void = { scrutoff = lc.off; scrutt = resolvetagged(c, lc.tnode); }; + } else { + // Non-ident scrutinee (call result, arr[i], p.field, ?, + // etc.). Mirror cgmatch's spill (cgenexpr.ww:1422-1460) + // and cstage cmd/w6c/cgen.c:6300-6316: alloc an + // `@asrt_spill` slot sized via matchspillsz, evaluate + // the LHS, then copy the AX/DX/CX[/R8] return-ABI words + // into the slot so the tag-check + payload load indexes + // off memory like the IDENT path. Without this, scrutoff + // stayed 0 and the tag read fell on (BP) — the saved-BP + // word — and the payload read on +8(BP) — the return + // address. Bug #200. + scrutt = matchscrutt(c, lhs); + let spillsz: i32 = matchspillsz(c, scrutt); + scrutoff = localalloc(c, "@asrt_spill", spillsz, nil); + cgexpr(c, lhs); + emitline("\tMOVQ\tAX, "); + emitoff(scrutoff: i64); + emitline("(BP)\n"); + if (!isnullabletype(scrutt)) { + emitline("\tMOVQ\tDX, "); + emitoff((scrutoff + 8): i64); + emitline("(BP)\n"); + // Mirror cstage cmd/w6c/cgen.c:6313-6315: only CX + // → +16 when slot_size > 16. The 4-word case + // (R8 → +24, slot_size > 24) is the cgmatch shape + // (cgenexpr.ww:1454-1458, cmd/w6c/cgen.c:5988-5990) + // but cstage cgtypeassert omits it; preserve the + // asymmetry rather than diverge from rule 10 + // byte-id. Filed inline as a cstage twin task. + if (spillsz > 16) { + emitline("\tMOVQ\tCX, "); + emitoff((scrutoff + 16): i64); + emitline("(BP)\n"); + }; + }; }; }; let want: i32 = cgtagvariantidx(c, scrutt, n.rhs); diff --git a/selfhost/cmd/wcc/cgenexpr.ww b/selfhost/cmd/wcc/cgenexpr.ww index 1d2b8433..d9ac1f9f 100644 --- a/selfhost/cmd/wcc/cgenexpr.ww +++ b/selfhost/cmd/wcc/cgenexpr.ww @@ -387,6 +387,41 @@ fn cgtypeassert(c: *cgen, n: *node) void = { scrutoff = lc.off; scrutt = resolvetagged(c, lc.tnode); }; + } else { + // Non-ident scrutinee (call result, arr[i], p.field, ?, + // etc.). Mirror cgmatch's spill (cgenexpr.ww:1422-1460) + // and cstage cmd/w6c/cgen.c:6300-6316: alloc an + // `@asrt_spill` slot sized via matchspillsz, evaluate + // the LHS, then copy the AX/DX/CX[/R8] return-ABI words + // into the slot so the tag-check + payload load indexes + // off memory like the IDENT path. Without this, scrutoff + // stayed 0 and the tag read fell on (BP) — the saved-BP + // word — and the payload read on +8(BP) — the return + // address. Bug #200. + scrutt = matchscrutt(c, lhs); + let spillsz: i32 = matchspillsz(c, scrutt); + scrutoff = localalloc(c, "@asrt_spill", spillsz, nil); + cgexpr(c, lhs); + emitline("\tMOVQ\tAX, "); + emitoff(scrutoff: i64); + emitline("(BP)\n"); + if (!isnullabletype(scrutt)) { + emitline("\tMOVQ\tDX, "); + emitoff((scrutoff + 8): i64); + emitline("(BP)\n"); + // Mirror cstage cmd/w6c/cgen.c:6313-6315: only CX + // → +16 when slot_size > 16. The 4-word case + // (R8 → +24, slot_size > 24) is the cgmatch shape + // (cgenexpr.ww:1454-1458, cmd/w6c/cgen.c:5988-5990) + // but cstage cgtypeassert omits it; preserve the + // asymmetry rather than diverge from rule 10 + // byte-id. Filed inline as a cstage twin task. + if (spillsz > 16) { + emitline("\tMOVQ\tCX, "); + emitoff((scrutoff + 16): i64); + emitline("(BP)\n"); + }; + }; }; }; let want: i32 = cgtagvariantidx(c, scrutt, n.rhs); diff --git a/selfhost/cmd/wwdump/main.combined.ww b/selfhost/cmd/wwdump/main.combined.ww index 785fb0a4..0b6a2fd9 100644 --- a/selfhost/cmd/wwdump/main.combined.ww +++ b/selfhost/cmd/wwdump/main.combined.ww @@ -18010,6 +18010,41 @@ fn cgtypeassert(c: *cgen, n: *node) void = { scrutoff = lc.off; scrutt = resolvetagged(c, lc.tnode); }; + } else { + // Non-ident scrutinee (call result, arr[i], p.field, ?, + // etc.). Mirror cgmatch's spill (cgenexpr.ww:1422-1460) + // and cstage cmd/w6c/cgen.c:6300-6316: alloc an + // `@asrt_spill` slot sized via matchspillsz, evaluate + // the LHS, then copy the AX/DX/CX[/R8] return-ABI words + // into the slot so the tag-check + payload load indexes + // off memory like the IDENT path. Without this, scrutoff + // stayed 0 and the tag read fell on (BP) — the saved-BP + // word — and the payload read on +8(BP) — the return + // address. Bug #200. + scrutt = matchscrutt(c, lhs); + let spillsz: i32 = matchspillsz(c, scrutt); + scrutoff = localalloc(c, "@asrt_spill", spillsz, nil); + cgexpr(c, lhs); + emitline("\tMOVQ\tAX, "); + emitoff(scrutoff: i64); + emitline("(BP)\n"); + if (!isnullabletype(scrutt)) { + emitline("\tMOVQ\tDX, "); + emitoff((scrutoff + 8): i64); + emitline("(BP)\n"); + // Mirror cstage cmd/w6c/cgen.c:6313-6315: only CX + // → +16 when slot_size > 16. The 4-word case + // (R8 → +24, slot_size > 24) is the cgmatch shape + // (cgenexpr.ww:1454-1458, cmd/w6c/cgen.c:5988-5990) + // but cstage cgtypeassert omits it; preserve the + // asymmetry rather than diverge from rule 10 + // byte-id. Filed inline as a cstage twin task. + if (spillsz > 16) { + emitline("\tMOVQ\tCX, "); + emitoff((scrutoff + 16): i64); + emitline("(BP)\n"); + }; + }; }; }; let want: i32 = cgtagvariantidx(c, scrutt, n.rhs); diff --git a/test/wcc/772_typeassert_nonident.c b/test/wcc/772_typeassert_nonident.c new file mode 100644 index 00000000..2f07505c --- /dev/null +++ b/test/wcc/772_typeassert_nonident.c @@ -0,0 +1,332 @@ +/* + * 772_typeassert_nonident — project #200: wwstage cgtypeassert non-IDENT + * scrutinee bug. The pre-fix `e as T` on a non-N_IDENT scrutinee (direct + * call result, arr[i], p.field, ?, paren-wrap of any of those) left + * scrutoff=0; the tag-load fell on (BP) — the saved-BP word — and the + * payload-load on +8(BP) — the return address — instead of the spilled + * tagged-return ABI words. wwstage produced garbage (exit 220 in the + * repro vs cstage's 42). + * + * Fix (selfhost/cmd/wcc/cgenexpr.ww:368-414 cgtypeassert): mirror cstage + * cmd/w6c/cgen.c:6300-6316 N_TYPEASSERT non-IDENT arm. Add an `else` + * branch after the existing N_IDENT path: resolve the tagged type via + * matchscrutt, alloc an `@asrt_spill` slot via matchspillsz/localalloc, + * cgexpr the LHS, then spill AX→+0 (tag), DX→+8 (word0), CX→+16 + * (word1) guarded on spill > 16. Mirrors cgmatch's spill (cgenexpr.ww: + * 1422-1460). The R8→+24 path (cgmatch shape) is intentionally omitted + * to mirror cstage's cgtypeassert exactly (rule-10 byte-id); the cstage + * twin gap is filed inline as a sibling task. + * + * Coverage (7 rows): + * 1. call_as_size — `f() as size`. The repro shape: direct call + * result, basic-size payload. Pre-fix + * wwstage: garbage AX from (BP). Post-fix: + * 42. + * 2. call_as_str — `f() as str`. Non-IDENT, multi-word payload + * (AX=ptr / BX=len). Tests CX→+16 spill + + * the `isstrtype` post-spill BX load. + * 3. call_as_namedvoid — `f() as nvariant` where nvariant is a + * named-void alias. Tag-check fires; the + * payload load is a 0-byte no-op (void + * variant has size 0; no consumer reads AX). + * Drew-add. + * 4. call_as_fnptr — `f() as *fn(i32) i32`. Fn-ptr variant + * (8B word0 = pointer). Round-trips through + * spill→tag-check→MOVQ +8 → AX. + * 5. call_as_u8 — `f() as u8`. Narrow scalar. The callee + * zero/sign-extended the value to 8B on + * return; MOVQ store + MOVQ read round-trips + * it. Ken-add: confirms no narrow-truncation + * on the payload load. + * 6. call_as_i16 — `f() as i16`. Narrow signed scalar. Tests + * negative round-trip through the spill slot + * (callee sign-extended to 8B; MOVQ + * preserves). Ken-add sibling of row 5. + * 7. branched_call_as — callee returns a runtime-chosen variant + * (the success branch); `f(true) as size`. + * Tests that the spill+tag-check path + * doesn't lose the callee's selected tag. + * + * Per-row gates: cstage runtime exit, wwstage runtime exit, cs.s == + * ww.s byte-identical (rule-10 stage symmetry). + * + * GATE POLARITY: must stay GREEN. A red on rows 1-4 or 7 means the + * spill path is broken (non-IDENT scrutinee returned to the BP/RIP + * read). Rows 5/6 red means narrow-scalar round-trip miscompiled at + * the payload load (would surface if a future SIB regresses MOVQ to a + * narrow load that drops the callee's extension). + */ +#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; +} + +#define STAGE_CS 1 +#define STAGE_WW 2 + +struct row { + const char *label; + const char *src; + int expected_exit; + int stage_mask; +}; + +static const struct row rows[] = { + { "call_as_size", + "package main;\n" + "fn f(b: bool) (size | str) = {\n" + " if (b) { return 42: size; };\n" + " return \"x\";\n" + "};\n" + "export fn main() i32 = {\n" + " let n = f(true) as size;\n" + " return n: i32;\n" + "};\n", + 42, + STAGE_CS | STAGE_WW }, + { "call_as_str", + "package main;\n" + "fn f(b: bool) (size | str) = {\n" + " if (b) { return \"AB\"; };\n" + " return 0: size;\n" + "};\n" + "export fn main() i32 = {\n" + " let s = f(true) as str;\n" + " return s.len: i32 + 40;\n" + "};\n", + 42, + STAGE_CS | STAGE_WW }, + { "call_as_namedvoid", + "package main;\n" + "type nv = void;\n" + "fn f(b: bool) (size | nv) = {\n" + " if (b) { return void: nv; };\n" + " return 0: size;\n" + "};\n" + "export fn main() i32 = {\n" + " let _ = f(true) as nv;\n" + " return 42;\n" + "};\n", + 42, + STAGE_CS | STAGE_WW }, + { "call_as_fnptr", + /* #193: explicit deref required to call a fn-ptr (`(*p)(args)`). + * The probe is wwstage cgtypeassert non-IDENT spill; deref-call + * shape is orthogonal. */ + "package main;\n" + "fn g(x: i32) i32 = { return x + 1; };\n" + "fn f(b: bool) (*fn(x: i32) i32 | size) = {\n" + " if (b) { return &g; };\n" + " return 0: size;\n" + "};\n" + "export fn main() i32 = {\n" + " let p = f(true) as *fn(x: i32) i32;\n" + " return (*p)(41);\n" + "};\n", + 42, + STAGE_CS | STAGE_WW }, + { "call_as_u8", + "package main;\n" + "fn f(b: bool) (u8 | str) = {\n" + " if (b) { return 42: u8; };\n" + " return \"x\";\n" + "};\n" + "export fn main() i32 = {\n" + " let n = f(true) as u8;\n" + " return n: i32;\n" + "};\n", + 42, + STAGE_CS | STAGE_WW }, + { "call_as_i16", + "package main;\n" + "fn f(b: bool) (i16 | str) = {\n" + " if (b) { return -1: i16; };\n" + " return \"x\";\n" + "};\n" + "export fn main() i32 = {\n" + " let n = f(true) as i16;\n" + " if (n: i32 == -1) { return 42; };\n" + " return 1;\n" + "};\n", + 42, + STAGE_CS | STAGE_WW }, + { "branched_call_as", + "package main;\n" + "fn f(b: bool) (size | str) = {\n" + " if (b) { return 42: size; };\n" + " return \"x\";\n" + "};\n" + "export fn main() i32 = {\n" + " let pick: bool = true;\n" + " let n = f(pick) as size;\n" + " return n: i32;\n" + "};\n", + 42, + STAGE_CS | STAGE_WW }, +}; + +static int +write_source(const char *path, const char *src) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs(src, f); + fclose(f); + return 0; +} + +static void +cleanup_tmp(const char *tmpdir, const char *base) +{ + char p[512]; + snprintf(p, sizeof p, "%s/%s.ww", tmpdir, base); unlink(p); + snprintf(p, sizeof p, "%s/%s.s", tmpdir, base); unlink(p); + snprintf(p, sizeof p, "%s/%s.o", tmpdir, base); unlink(p); + snprintf(p, sizeof p, "%s/%s.combined.ww", tmpdir, base); unlink(p); + snprintf(p, sizeof p, "%s/%s", tmpdir, base); unlink(p); + rmdir(tmpdir); +} + +static int +build_via_driver(const char *driver, const char *tmpdir, const char *src) +{ + char cmd[1024]; + snprintf(cmd, sizeof cmd, "cd %s && timeout 180 %s build %s 2>/dev/null", + tmpdir, driver, src); + return runwait(cmd); +} + +static int +run_row(const char *driver, const struct row *r, int seq) +{ + char tmpdir[256], src[512], base[64], outbin[768]; + snprintf(tmpdir, sizeof tmpdir, "/tmp/tan_%d_d_%d", getpid(), seq); + snprintf(base, sizeof base, "main772"); + snprintf(src, sizeof src, "%s/%s.ww", tmpdir, base); + mkdir(tmpdir, 0755); + if (write_source(src, r->src) != 0) { cleanup_tmp(tmpdir, base); return -1; } + int rc = -1; + if (build_via_driver(driver, tmpdir, src) == 0) { + snprintf(outbin, sizeof outbin, "%s/%s", tmpdir, base); + rc = runwait(outbin); + } + cleanup_tmp(tmpdir, base); + return rc; +} + +/* asm_byte_identical — diff cstage vs wwstage .s. Parallel trees so + * ww_ww writing intermediates next to the source doesn't clobber the + * cstage .s (CLAUDE.md rule 14 phase split). */ +static int +asm_byte_identical(const char *cdrv, const char *wdrv, + const struct row *r, int seq) +{ + char src[512], tdc[256], tdw[256], base[64], cs[512], ws[512]; + snprintf(tdc, sizeof tdc, "/tmp/tan_%d_c_%d", getpid(), seq); + snprintf(tdw, sizeof tdw, "/tmp/tan_%d_w_%d", getpid(), seq); + snprintf(base, sizeof base, "main772"); + mkdir(tdc, 0755); + mkdir(tdw, 0755); + snprintf(src, sizeof src, "%s/%s.ww", tdc, base); + if (write_source(src, r->src) != 0) { cleanup_tmp(tdc, base); cleanup_tmp(tdw, base); return -1; } + int rc = -1; + if (build_via_driver(cdrv, tdc, src) != 0) goto out; + snprintf(cs, sizeof cs, "%s/%s.s", tdc, base); + + snprintf(src, sizeof src, "%s/%s.ww", tdw, base); + if (write_source(src, r->src) != 0) goto out; + if (build_via_driver(wdrv, tdw, src) != 0) goto out; + snprintf(ws, sizeof ws, "%s/%s.s", tdw, base); + + FILE *fc = fopen(cs, "rb"); + FILE *fw = fopen(ws, "rb"); + if (fc && fw) { + rc = 0; + for (;;) { + int a = fgetc(fc); + int b = fgetc(fw); + if (a != b) { rc = -1; break; } + if (a == EOF) break; + } + } + if (fc) fclose(fc); + if (fw) fclose(fw); +out: + cleanup_tmp(tdc, base); + cleanup_tmp(tdw, base); + return rc; +} + +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], wdrv[640]; + snprintf(cdrv, sizeof cdrv, "%s/ww", bin); + snprintf(wdrv, sizeof wdrv, "%s/ww_ww", bin); + + int n = (int)(sizeof rows / sizeof rows[0]); + int total = 0, fail = 0; + int wwpresent = (access(wdrv, X_OK) == 0); + int seq = 0; + + for (int i = 0; i < n; i++) { + if (rows[i].stage_mask & STAGE_CS) { + total++; + int got = run_row(cdrv, &rows[i], seq++); + if (got != rows[i].expected_exit) { + fprintf(stderr, + "typeassert_nonident[cstage run][%s]: exit=%d want=%d\n", + rows[i].label, got, rows[i].expected_exit); + fail++; + } + } + if (wwpresent && (rows[i].stage_mask & STAGE_WW)) { + total++; + int got = run_row(wdrv, &rows[i], seq++); + if (got != rows[i].expected_exit) { + fprintf(stderr, + "typeassert_nonident[wwstage run][%s]: exit=%d want=%d\n", + rows[i].label, got, rows[i].expected_exit); + fail++; + } + total++; + if (asm_byte_identical(cdrv, wdrv, &rows[i], seq++) != 0) { + fprintf(stderr, + "typeassert_nonident[byte-id][%s]: cstage vs wwstage asm differs\n", + rows[i].label); + fail++; + } + } + } + + if (!wwpresent) + fprintf(stderr, "typeassert_nonident: skip wwstage (no %s)\n", wdrv); + + if (fail) { + fprintf(stderr, "typeassert_nonident: %d/%d fixtures failed\n", + fail, total); + return 1; + } + printf("typeassert_nonident: %d/%d ok\n", total, total); + return 0; +}