From c34abf47b1b1edc2f1dacd14eeecbd5c88cfeb84 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Mon, 18 May 2026 10:20:01 +0900 Subject: [PATCH] selfhost+test: route N_DOT callee through fnparamslookupmod (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wwstage cgcall now mirrors cstage's typed-AST callee-params lookup for module-qualified mod.fn(...) calls, restoring tagged- union widening on cross-module slice args. Class A runtime miscompile — masked from 995_self_rebuild because wwstage tools don't call bytes.index directly; surfaced by lib/strings landing dragging utf8 + bytes into the wwstage-tool dep chain via strings.byteindex's `bytes.X(toutf8(...), n)` call sites. Pre-fix: wwstage's cgcall (cgenexpr.ww) looked up calleeparams only when callee.kind == N_IDENT. For N_DOT callees (the module-qualified mod.fn() form), calleeparams stayed nil → pushargsrev's widening detection gated on param != nil never fired → wwstage fell through to the N_IDENT-slice fast path pushing only 3 slot words (cap, len, ptr) WITHOUT the variant tag. Receiving fn's `match (needle)` then dispatched on (needle.ptr in CX) instead of needle.tag, with R8/R9 carrying .len/.cap instead of .ptr/.len. Wrong arm + wrong payload. Cstage handles N_DOT natively via the checker-set type on n->lhs->type (cmd/w6c/cgen.c:4156-4165), so cg_widen_tagged_push slice path pushes 4 words including tag. Polarity catalog: wwstage UNDER — calleeparams lookup missing N_DOT dispatch arm. Sister to #19 (N_TSLICE variantindex arm), #21 (N_CALL pushargsrev arm), #24 (N_CALL nodeisslice arm), #27 (aliaslookup same-mod-first). The pattern: wwstage dispatchers keep missing arms cstage has natively via typed-AST resolution. Convergence wwstage → cstage (rule 10's spirit overrides letter when correctness is at stake — Path 2 of aligning cstage DOWN would create a runtime miscompile in both stages). Fix: cgcall N_DOT branch pulls module from callee.lhs.str and function name from callee.str, calls new fnparamslookupmod helper. Helper does same-module-first walk then existing first-match fallback (mirrors #27's aliaslookup fix shape). New fnret.fmod field carries module identity; collectfnrets sets f.fmod = d.module at registration. Module-qualified pkg.fn path unchanged. Tests: - 727_modcall_widen_slice pins MOVQ $1 + PUSHQ AX (tag-synth) before the receiving fn's CALL on canonical mod.fn(slice, ...) shape with the callee param widened to a tagged union. Three assertions per row: cstage tag-synth presence, wwstage tag-synth presence, cstage↔wwstage cmp -s byte-id. Sentinel- flip-verified: comment out fnparamslookupmod call → wwstage tag-synth absent + cmp diverges. 99/99 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id). --- Makefile | 5 + selfhost/cmd/w6c/main.combined.ww | 44 ++++- selfhost/cmd/wcc/cgen.ww | 23 ++- selfhost/cmd/wcc/cgenexpr.ww | 21 ++- selfhost/cmd/wwdump/main.combined.ww | 44 ++++- test/wcc/727_modcall_widen_slice.c | 230 +++++++++++++++++++++++++++ 6 files changed, 355 insertions(+), 12 deletions(-) create mode 100644 test/wcc/727_modcall_widen_slice.c diff --git a/Makefile b/Makefile index dcb354dd..679524ed 100644 --- a/Makefile +++ b/Makefile @@ -255,6 +255,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_letdecl_zeroinit \ $(BIN)/test_nested_if_labels \ $(BIN)/test_alias_leaf_collision \ + $(BIN)/test_modcall_widen_slice \ $(BIN)/test_param_shadow_mod \ $(BIN)/test_localoff_scope \ $(BIN)/test_cast_enum_movl \ @@ -582,6 +583,10 @@ $(BIN)/test_alias_leaf_collision: test/wcc/726_alias_leaf_collision.c \ $(BIN)/w6c $(BIN)/w6c_ww | $(BIN) $(CC) $(CFLAGS) -o $@ $< +$(BIN)/test_modcall_widen_slice: test/wcc/727_modcall_widen_slice.c \ + $(BIN)/w6c $(BIN)/w6c_ww | $(BIN) + $(CC) $(CFLAGS) -o $@ $< + $(BIN)/test_use_promote_alias: test/wcc/699_use_promote_alias.c \ $(BIN)/ww $(BIN)/w6c $(BIN)/w6a $(BIN)/w6l \ $(LIB)/libwwrt.a | $(BIN) diff --git a/selfhost/cmd/w6c/main.combined.ww b/selfhost/cmd/w6c/main.combined.ww index 9dfa8b81..3bacdca4 100644 --- a/selfhost/cmd/w6c/main.combined.ww +++ b/selfhost/cmd/w6c/main.combined.ww @@ -12954,13 +12954,28 @@ fn cgcall(c: *cgen, n: *node) void = { // Look up the callee's declared params for tagged-union widening. // fn-pointer calls (callee is a local) don't get widening — the - // user must build the tagged value explicitly. Matches the most - // common case (direct named calls). + // user must build the tagged value explicitly. + // + // N_DOT (`mod.fn(...)`) covers cross-module calls; pre-#28 wwstage + // only handled N_IDENT, leaving N_DOT calls without widening + // detection — pushargsrev then fell through to the N_IDENT-slice + // fast path and dropped the variant tag word on widened slice args. + // Cstage finds params via the checker-set `n->lhs->type`, sidestepping + // the name-driven registry entirely (cmd/w6c/cgen.c:4161-4165). let calleeparams: *node = nil; if (callee != nil) { if (callee.kind == nkind.N_IDENT) { calleeparams = fnparamslookup(c, callee.str); - }; + } else { if (callee.kind == nkind.N_DOT) { + let cmod: str; + cmod.ptr = nil; cmod.len = 0; + if (callee.lhs != nil) { + if (callee.lhs.kind == nkind.N_IDENT) { + cmod = callee.lhs.str; + }; + }; + calleeparams = fnparamslookupmod(c, callee.str, cmod); + }; }; }; // Hare-style variadic last param: gather N tail args into a // frame-resident [N]T (`@vararg_d_`) plus a 24B slice @@ -19648,6 +19663,7 @@ fn emitdatasection(c: *cgen) void = { type fnret = struct { fname: str, + fmod: str, rtype: *node, params: *node, frnext: *fnret, @@ -19658,8 +19674,9 @@ fn collectfnrets(c: *cgen, file: *node) void = { let d: *node = file.list; for (d != nil) { if (d.kind == nkind.N_FNDECL) { - let f: *fnret = amalloc(c.a, 48u64): *fnret; + let f: *fnret = amalloc(c.a, 64u64): *fnret; f.fname = d.str; + f.fmod = d.module; f.rtype = d.lhs; f.params = d.list; f.frnext = c.fnrets; @@ -19692,6 +19709,25 @@ fn fnparamslookup(c: *cgen, name: str) *node = { return nil; }; +// fnparamslookupmod — same-module-first leaf walk. Module-qualified +// `mod.fn(...)` calls go through this so a leaf collision (multiple +// modules export the same name, e.g. `os.read` and `io.read`) resolves +// to the explicit module. Falls back to the first leaf match if no +// matching module is registered — mirrors aliaslookup's two-pass shape +// (cgen.ww:75, fixed in #27). +fn fnparamslookupmod(c: *cgen, name: str, mod: str) *node = { + if (mod.len > 0) { + let f: *fnret = c.fnrets; + for (f != nil) { + if (streq(f.fname, name)) { + if (streq(f.fmod, mod)) { return f.params; }; + }; + f = f.frnext; + }; + }; + return fnparamslookup(c, name); +}; + // ---- def-constant registry ------------------------------------------ // // `def NAME: T = LIT;` becomes a DATA symbol the C-side w6c emits; an diff --git a/selfhost/cmd/wcc/cgen.ww b/selfhost/cmd/wcc/cgen.ww index 10df0b91..73df7ee6 100644 --- a/selfhost/cmd/wcc/cgen.ww +++ b/selfhost/cmd/wcc/cgen.ww @@ -1515,6 +1515,7 @@ fn emitdatasection(c: *cgen) void = { type fnret = struct { fname: str, + fmod: str, rtype: *node, params: *node, frnext: *fnret, @@ -1525,8 +1526,9 @@ fn collectfnrets(c: *cgen, file: *node) void = { let d: *node = file.list; for (d != nil) { if (d.kind == nkind.N_FNDECL) { - let f: *fnret = amalloc(c.a, 48u64): *fnret; + let f: *fnret = amalloc(c.a, 64u64): *fnret; f.fname = d.str; + f.fmod = d.module; f.rtype = d.lhs; f.params = d.list; f.frnext = c.fnrets; @@ -1559,6 +1561,25 @@ fn fnparamslookup(c: *cgen, name: str) *node = { return nil; }; +// fnparamslookupmod — same-module-first leaf walk. Module-qualified +// `mod.fn(...)` calls go through this so a leaf collision (multiple +// modules export the same name, e.g. `os.read` and `io.read`) resolves +// to the explicit module. Falls back to the first leaf match if no +// matching module is registered — mirrors aliaslookup's two-pass shape +// (cgen.ww:75, fixed in #27). +fn fnparamslookupmod(c: *cgen, name: str, mod: str) *node = { + if (mod.len > 0) { + let f: *fnret = c.fnrets; + for (f != nil) { + if (streq(f.fname, name)) { + if (streq(f.fmod, mod)) { return f.params; }; + }; + f = f.frnext; + }; + }; + return fnparamslookup(c, name); +}; + // ---- def-constant registry ------------------------------------------ // // `def NAME: T = LIT;` becomes a DATA symbol the C-side w6c emits; an diff --git a/selfhost/cmd/wcc/cgenexpr.ww b/selfhost/cmd/wcc/cgenexpr.ww index 42d28be7..5731472f 100644 --- a/selfhost/cmd/wcc/cgenexpr.ww +++ b/selfhost/cmd/wcc/cgenexpr.ww @@ -2856,13 +2856,28 @@ fn cgcall(c: *cgen, n: *node) void = { // Look up the callee's declared params for tagged-union widening. // fn-pointer calls (callee is a local) don't get widening — the - // user must build the tagged value explicitly. Matches the most - // common case (direct named calls). + // user must build the tagged value explicitly. + // + // N_DOT (`mod.fn(...)`) covers cross-module calls; pre-#28 wwstage + // only handled N_IDENT, leaving N_DOT calls without widening + // detection — pushargsrev then fell through to the N_IDENT-slice + // fast path and dropped the variant tag word on widened slice args. + // Cstage finds params via the checker-set `n->lhs->type`, sidestepping + // the name-driven registry entirely (cmd/w6c/cgen.c:4161-4165). let calleeparams: *node = nil; if (callee != nil) { if (callee.kind == nkind.N_IDENT) { calleeparams = fnparamslookup(c, callee.str); - }; + } else { if (callee.kind == nkind.N_DOT) { + let cmod: str; + cmod.ptr = nil; cmod.len = 0; + if (callee.lhs != nil) { + if (callee.lhs.kind == nkind.N_IDENT) { + cmod = callee.lhs.str; + }; + }; + calleeparams = fnparamslookupmod(c, callee.str, cmod); + }; }; }; // Hare-style variadic last param: gather N tail args into a // frame-resident [N]T (`@vararg_d_`) plus a 24B slice diff --git a/selfhost/cmd/wwdump/main.combined.ww b/selfhost/cmd/wwdump/main.combined.ww index 9d659204..223b224d 100644 --- a/selfhost/cmd/wwdump/main.combined.ww +++ b/selfhost/cmd/wwdump/main.combined.ww @@ -12954,13 +12954,28 @@ fn cgcall(c: *cgen, n: *node) void = { // Look up the callee's declared params for tagged-union widening. // fn-pointer calls (callee is a local) don't get widening — the - // user must build the tagged value explicitly. Matches the most - // common case (direct named calls). + // user must build the tagged value explicitly. + // + // N_DOT (`mod.fn(...)`) covers cross-module calls; pre-#28 wwstage + // only handled N_IDENT, leaving N_DOT calls without widening + // detection — pushargsrev then fell through to the N_IDENT-slice + // fast path and dropped the variant tag word on widened slice args. + // Cstage finds params via the checker-set `n->lhs->type`, sidestepping + // the name-driven registry entirely (cmd/w6c/cgen.c:4161-4165). let calleeparams: *node = nil; if (callee != nil) { if (callee.kind == nkind.N_IDENT) { calleeparams = fnparamslookup(c, callee.str); - }; + } else { if (callee.kind == nkind.N_DOT) { + let cmod: str; + cmod.ptr = nil; cmod.len = 0; + if (callee.lhs != nil) { + if (callee.lhs.kind == nkind.N_IDENT) { + cmod = callee.lhs.str; + }; + }; + calleeparams = fnparamslookupmod(c, callee.str, cmod); + }; }; }; // Hare-style variadic last param: gather N tail args into a // frame-resident [N]T (`@vararg_d_`) plus a 24B slice @@ -19648,6 +19663,7 @@ fn emitdatasection(c: *cgen) void = { type fnret = struct { fname: str, + fmod: str, rtype: *node, params: *node, frnext: *fnret, @@ -19658,8 +19674,9 @@ fn collectfnrets(c: *cgen, file: *node) void = { let d: *node = file.list; for (d != nil) { if (d.kind == nkind.N_FNDECL) { - let f: *fnret = amalloc(c.a, 48u64): *fnret; + let f: *fnret = amalloc(c.a, 64u64): *fnret; f.fname = d.str; + f.fmod = d.module; f.rtype = d.lhs; f.params = d.list; f.frnext = c.fnrets; @@ -19692,6 +19709,25 @@ fn fnparamslookup(c: *cgen, name: str) *node = { return nil; }; +// fnparamslookupmod — same-module-first leaf walk. Module-qualified +// `mod.fn(...)` calls go through this so a leaf collision (multiple +// modules export the same name, e.g. `os.read` and `io.read`) resolves +// to the explicit module. Falls back to the first leaf match if no +// matching module is registered — mirrors aliaslookup's two-pass shape +// (cgen.ww:75, fixed in #27). +fn fnparamslookupmod(c: *cgen, name: str, mod: str) *node = { + if (mod.len > 0) { + let f: *fnret = c.fnrets; + for (f != nil) { + if (streq(f.fname, name)) { + if (streq(f.fmod, mod)) { return f.params; }; + }; + f = f.frnext; + }; + }; + return fnparamslookup(c, name); +}; + // ---- def-constant registry ------------------------------------------ // // `def NAME: T = LIT;` becomes a DATA symbol the C-side w6c emits; an diff --git a/test/wcc/727_modcall_widen_slice.c b/test/wcc/727_modcall_widen_slice.c new file mode 100644 index 00000000..8258ae27 --- /dev/null +++ b/test/wcc/727_modcall_widen_slice.c @@ -0,0 +1,230 @@ +/* + * 727_modcall_widen_slice — sentinel for #28. Pins wwstage's cgcall to + * look up calleeparams for module-qualified `mod.fn(...)` callees, so + * pushargsrev's widening detection fires for an N_IDENT slice arg + * passed to a tagged-union parameter slot. + * + * Pre-fix wwstage `cgcall` (selfhost/cmd/wcc/cgenexpr.ww:2861) only + * called fnparamslookup when `callee.kind == nkind.N_IDENT`. For + * `mod.fn(...)` (N_DOT callee), `calleeparams` stayed nil; pushargsrev's + * widening detection is gated on `param != nil` so it never fired; + * the N_IDENT-slice fast path (cgenutil.ww:368-383) then pushed only + * 3 slot words (ptr/len/cap) and DROPPED the variant tag word. The + * callee subsequently dispatched on (callee-arg-reg holds ptr instead + * of tag) — a runtime miscompile, not a pure byte-id divergence. + * + * Cstage finds params via `n->lhs->type` (the checker-set type on the + * N_DOT callee node, cmd/w6c/cgen.c:4161-4165), bypassing the name- + * driven registry. Wwstage needed the mirror via N_DOT.lhs.str. + * + * Same-family sister of #21 (N_CALL dispatch in pushargsrev), #19 + * (N_TSLICE match arm), #24 (N_CALL slice arm) — the pattern is + * wwstage dispatchers periodically missing arms cstage gets natively + * via typed AST. + * + * Bootstrap-blocking when lib/strings v3 (drags utf8 + bytes into the + * build) lands. + */ +#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; +} + +struct row { + const char *label; + const char *src; +}; + +/* Two-module probe: `caller` calls `needle.want(h, n)` where `n: []u8` + * widens into `(u8 | []u8)`. The // MODULE: directives + `use needle;` + * shape parallels what cmd/ww driver synthesises in combined.ww. */ +static const struct row rows[] = { + { "dot_callee_slice_widen", + "// MODULE: needle\n" + "export fn want(haystack: []u8, needle: (u8 | []u8)) i32 = {\n" + " let r: i32 = haystack.len;\n" + " match (needle) {\n" + " case let b: u8 => r = r + (b: i32);\n" + " case let s: []u8 => r = r + s.len;\n" + " };\n" + " return r;\n" + "};\n" + "// MODULE: caller\n" + "use needle;\n" + "export fn main() i32 = {\n" + " let h: []u8;\n" + " let n: []u8 = h;\n" + " return needle.want(h, n);\n" + "};\n" }, +}; + +static int +slurp(const char *path, char *buf, size_t cap) +{ + FILE *f = fopen(path, "rb"); + if (!f) return -1; + size_t n = fread(buf, 1, cap - 1, f); + fclose(f); + buf[n] = '\0'; + return (int)n; +} + +static int +emit_s(const char *w6c, const struct row *r, int i, char *out_s, size_t cap) +{ + char src[64], cmd[1024]; + snprintf(src, sizeof src, "/tmp/mws_%d_%d.ww", getpid(), i); + snprintf(out_s, cap, "/tmp/mws_%d_%d_%s.s", + getpid(), i, w6c[strlen(w6c) - 1] == 'w' ? "ww" : "c"); + + FILE *f = fopen(src, "wb"); + if (!f) return -1; + fputs(r->src, f); + fclose(f); + + snprintf(cmd, sizeof cmd, "%s -o %s %s 2>/dev/null", w6c, out_s, src); + int rc = runwait(cmd); + unlink(src); + return rc; +} + +/* Inside TEXT main, the call to needle.want must push 4 needle words + * (cap, len, ptr, tag) before the haystack pushes. Asserts the tag- + * synth `MOVQ $1, AX; PUSHQ AX` lands between the slice-triple pushes + * and the next-arg load. Pre-fix the tag synth is absent — the + * needle slot ends at the third PUSHQ AX. */ +static int +check_tag_push_present(const char *spath, const struct row *r, + const char *stage) +{ + char buf[1 << 14]; + if (slurp(spath, buf, sizeof buf) < 0) { + fprintf(stderr, "row[%s][%s]: cannot read %s\n", + r->label, stage, spath); + return -1; + } + const char *fn = strstr(buf, "TEXT main"); + if (!fn) { + fprintf(stderr, "row[%s][%s]: no TEXT main in %s\n", + r->label, stage, spath); + return -1; + } + const char *call = strstr(fn, "CALL\tneedle.want"); + if (!call) { + fprintf(stderr, "row[%s][%s]: no CALL needle.want\n", + r->label, stage); + return -1; + } + /* Tag synth: a `MOVQ\t$1, AX` followed by a `PUSHQ\tAX` between + * fn-start and the call. The literal `$1` is the slice-variant + * tag index in (u8 | []u8). */ + int found = 0; + const char *p = fn; + while (p < call) { + const char *m = strstr(p, "MOVQ\t$1, AX"); + if (!m || m >= call) break; + const char *nx = strstr(m, "PUSHQ\tAX"); + if (nx && nx < call) { found = 1; break; } + p = m + 1; + } + if (!found) { + fprintf(stderr, + "row[%s][%s]: no tag-synth `MOVQ $1, AX; PUSHQ AX` before CALL needle.want\n", + r->label, stage); + return -1; + } + /* Also assert the post-call `ADDQ $8, SP` (overflow cleanup) is + * emitted — 7 SysV reg slots for {haystack 3 + needle 4} leaves + * 1 word stack-overflowed. Pre-fix wwstage only pushed 6, so no + * cleanup either. */ + const char *cleanup = strstr(call, "ADDQ\t$8, SP"); + const char *nexttext = strstr(call, "TEXT "); + if (!cleanup || (nexttext && cleanup > nexttext)) { + fprintf(stderr, + "row[%s][%s]: no `ADDQ $8, SP` overflow cleanup after CALL needle.want\n", + r->label, stage); + return -1; + } + return 0; +} + +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 w6c[640], w6c_ww[640]; + snprintf(w6c, sizeof w6c, "%s/w6c", bin); + snprintf(w6c_ww, sizeof w6c_ww, "%s/w6c_ww", bin); + + int have_ww = (access(w6c_ww, X_OK) == 0); + int n = (int)(sizeof rows / sizeof rows[0]); + int total = 0, fail = 0; + + for (int i = 0; i < n; i++) { + char cs_path[128], ws_path[128]; + + if (emit_s(w6c, &rows[i], i, cs_path, sizeof cs_path) != 0) { + fprintf(stderr, + "modcall_widen_slice[cstage][%s]: w6c failed\n", + rows[i].label); + fail++; total++; continue; + } + total++; + if (check_tag_push_present(cs_path, &rows[i], "cstage") != 0) + fail++; + + if (!have_ww) { unlink(cs_path); continue; } + + if (emit_s(w6c_ww, &rows[i], i, ws_path, sizeof ws_path) != 0) { + fprintf(stderr, + "modcall_widen_slice[wwstage][%s]: w6c_ww failed\n", + rows[i].label); + fail++; total++; + unlink(cs_path); continue; + } + total++; + if (check_tag_push_present(ws_path, &rows[i], "wwstage") != 0) + fail++; + + /* byte-id between stages — the principled sentinel for the + * polarity catalog. */ + total++; + char cmd[512]; + snprintf(cmd, sizeof cmd, "cmp -s %s %s", cs_path, ws_path); + if (runwait(cmd) != 0) { + fprintf(stderr, + "modcall_widen_slice[%s]: cstage vs wwstage asm differs\n", + rows[i].label); + fail++; + } + + unlink(cs_path); unlink(ws_path); + } + + if (fail) { + fprintf(stderr, + "modcall_widen_slice: %d/%d fixtures failed\n", fail, total); + return 1; + } + printf("modcall_widen_slice: %d/%d ok\n", total, total); + return 0; +}