cstage+selfhost+test: zero unused ABI words in cgreturn variant-widen (#18)

cgreturn's variant-widen arm only filled the registers each variant's
payload needed: scalar variants left CX and R8 stale; str variant
left R8 stale. The receiver (cg_widen_tagged_store non-N_IDENT
branch) writes all four ABI words to the dst slot unconditionally,
so caller-side residue in CX/R8 (the array-index IMULQ being the
canonical primer) landed at slot+16 and slot+24.

Worker-fmtparser surfaced this through fprintf's loop body where
array indexing primed CX and a 24B-return helper failed to clear
it; bug isn't loop-specific — straight-line repro at /tmp/wcrs_repro/
repro8.ww confirms.

Patch: emit `MOVQ $0, CX` after the variant's register shuffle when
the slot exceeds 16B and the variant doesn't fill CX; same for R8
when the slot exceeds 24B. Symmetric across cstage cgen.c and
wwstage cgenstmt.ww. Order: zero-MOVQs precede `MOVQ $tag, AX` so
AX-as-staging stays safe. Inline comment at cg_widen_tagged_store
non-N_IDENT branch documents the producer-zero contract.

Test 707 (cgreturn_variant_zero): 6 rows × both stages = 12 fixtures.
Covers scalar/bool/str returns after array-index priming in
straight-line / single-loop body / nested-loop body / mixed-variant
loop. Asm byte-identity check intentionally omitted; wwstage
taggedvariantindex divergence on str/bool N_IDENT is filed as task
#20. 995_self_rebuild covers the broader cross-stage drift surface.

ww2 == ww3 == ww4 byte-identical post-fix. 67/67 green.

Deferred (followups filed): #20 wwstage taggedvariantindex,
#21 [N]str/[N]bool array-literal non-pointer-half writes, #22
consolidate variant-widen into uniform scratch-slot path.
This commit is contained in:
2026-05-16 01:37:15 +09:00
parent 6b6d7dd283
commit 166431a2da
6 changed files with 383 additions and 1 deletions

View File

@@ -0,0 +1,288 @@
/*
* 707_cgreturn_variant_zero — tagged-return ABI variant-widen zero-pad
* for unused AX/DX/CX/R8 words (task #18).
*
* Pre-fix: cgreturn's `!istagged && !isstruct` variant-widen arm only
* filled the registers a given variant actually uses (scalar → DX;
* str → DX, CX; slice → DX, CX, R8). The unused AX/CX/R8 words were
* left holding whatever the caller's last write parked there. The
* receive side (cg_widen_tagged_store call-source arm) writes
* AX/DX/CX/R8 into the dst slot unconditionally, sized by the slot
* total — so stale registers landed at slot+16 / slot+24 and silently
* corrupted the tagged-union slot.
*
* The corruption only surfaced when the caller had primed CX/R8
* shortly before the call. Array / slice indexing emits
* `MOVQ $stride, CX; IMULQ CX, AX` — the canonical primer. Happens
* once per index, including in for-loop bodies. Hence the original
* "for-loop miscompile" framing; the bug is in fact context-free,
* but an index expression in any straight-line code triggers it
* just as well.
*
* Fix: in cgreturn's variant-widen arm, after the variant-specific
* register shuffle and before the tag-into-AX, emit `MOVQ $0, CX` /
* `MOVQ $0, R8` for variants that don't naturally fill those words,
* conditional on the dst tagged-union slot size. Symmetric across
* cstage cgen.c and wwstage cgenstmt.ww.
*
* What this test pins (runtime only):
* - Every scalar-variant arm of a 24B tagged-union return zeroes
* slot+16 (CX) regardless of caller priming.
* - The str variant of a 24B slot still propagates .len correctly
* into slot+16 (the fix's `if (rsz > 24)` guard skips the R8
* zero for the 24B case, leaving CX/.len intact).
* - Cross-shape probes: straight-line array-index call, in-loop
* body call, nested-loop inner-body call.
*
* Asm byte-identity is NOT diffed here: wwstage has a pre-existing
* gap in taggedvariantindex's str/bool-N_IDENT lookup (returns 0
* regardless of declared variant), so non-i64 variant rows produce
* a divergent `MOVQ $tag, AX`. The fix in this commit IS symmetric
* (the new `MOVQ $0, CX` / `MOVQ $0, R8` lines match byte-for-byte
* across stages); global bootstrap byte-identity at test 995
* covers it. The wwstage variant-index gap is tracked separately.
*
* Test rows use `[N]i64` array indexing as the CX primer (well-
* supported on both stages) to dodge a pre-existing cstage/wwstage
* divergence in slice-creation reg scheduling.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.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. Straight-line array-index then scalar-variant 24B-tagged
* call. Array indexing emits MOVQ $8, CX; IMULQ CX, AX — so CX
* carries 8 into the call. Pre-fix: slot+16 = 8. Post-fix: 0. */
{ "scalar_24B_after_arridx",
"type ft = (i64 | str | bool);\n"
"fn aski64(v: i64) ft = { return v; };\n"
"fn main() i32 = {\n"
" let arr: [3]i64 = [10i64, 20i64, 30i64];\n"
" let i: i32 = 1;\n"
" let v: i64 = arr[i];\n"
" let a: ft = aski64(v);\n"
" let p: u64 = (&a): u64;\n"
" let p2: *i64 = (p + 16u64): *i64;\n"
" let w: i64 = *p2;\n"
" return w: i32;\n"
"};\n",
0 },
/* 2. Bool variant of the same 24B slot. i64-array-index primer
* (well-supported shape) -> derive bool -> call -> probe slot+16.
* Pre-fix: stride leak. Post-fix: 0. */
{ "bool_24B_after_arridx",
"type ft = (i64 | str | bool);\n"
"fn askbool(b: bool) ft = { return b; };\n"
"fn main() i32 = {\n"
" let arr: [3]i64 = [1i64, 0i64, 1i64];\n"
" let i: i32 = 0;\n"
" let v: i64 = arr[i];\n"
" let b: bool = v != 0i64;\n"
" let a: ft = askbool(b);\n"
" let p: u64 = (&a): u64;\n"
" let p2: *i64 = (p + 16u64): *i64;\n"
" let w: i64 = *p2;\n"
" return w: i32;\n"
"};\n",
0 },
/* 3. Str variant of a 24B slot — sender already fills CX with
* the len, so slot+16 was correct pre-fix too. Sanity row: must
* keep working; the fix's `if (rsz > 24)` guard skips the R8
* zero for the 24B case, leaving CX (and slot+16) at len. The
* i64-array index primes CX; the str literal feeds askstr. */
{ "str_24B_after_arridx",
"type ft = (i64 | str | bool);\n"
"fn askstr(s: str) ft = { return s; };\n"
"fn main() i32 = {\n"
" let arr: [3]i64 = [10i64, 20i64, 30i64];\n"
" let i: i32 = 1;\n"
" let _v: i64 = arr[i];\n"
" let a: ft = askstr(\"world\");\n"
" let p: u64 = (&a): u64;\n"
" let p2: *i64 = (p + 16u64): *i64;\n"
" let w: i64 = *p2;\n"
" return w: i32;\n"
"};\n",
5 },
/* 4. In-loop body: scalar variant call. Same shape as row 1,
* but inside a `for (i < 3)` body where IMULQ primes CX
* on every iteration. Pre-fix: slot+16 leaks 8 every iter;
* sum-of-leaks > 0. Post-fix: 0. */
{ "scalar_24B_in_loop",
"type ft = (i64 | str | bool);\n"
"fn aski64(v: i64) ft = { return v; };\n"
"fn main() i32 = {\n"
" let arr: [3]i64 = [10i64, 20i64, 30i64];\n"
" let i: i32 = 0;\n"
" let bad: i32 = 0;\n"
" for (i < 3) {\n"
" let v: i64 = arr[i];\n"
" let a: ft = aski64(v);\n"
" let p: u64 = (&a): u64;\n"
" let p2: *i64 = (p + 16u64): *i64;\n"
" let w: i64 = *p2;\n"
" if (w != 0i64) { bad += 1; };\n"
" i += 1;\n"
" };\n"
" return bad;\n"
"};\n",
0 },
/* 5. Nested loop, inner-body scalar-variant call. Pins that the
* fix doesn't depend on loop depth. Each inner iter primes CX. */
{ "scalar_24B_in_nested_loop",
"type ft = (i64 | str | bool);\n"
"fn aski64(v: i64) ft = { return v; };\n"
"fn main() i32 = {\n"
" let arr: [2]i64 = [10i64, 20i64];\n"
" let i: i32 = 0;\n"
" let bad: i32 = 0;\n"
" for (i < 2) {\n"
" let j: i32 = 0;\n"
" for (j < 2) {\n"
" let v: i64 = arr[j];\n"
" let a: ft = aski64(v);\n"
" let p: u64 = (&a): u64;\n"
" let p2: *i64 = (p + 16u64): *i64;\n"
" let w: i64 = *p2;\n"
" if (w != 0i64) { bad += 1; };\n"
" j += 1;\n"
" };\n"
" i += 1;\n"
" };\n"
" return bad;\n"
"};\n",
0 },
/* 6. Mixed loop: alternating str / i64 variants — pins that the
* scalar-variant zero doesn't regress the str-variant CX fill
* across iterations. iter-0 stores str (slot+16 = 5), iter-1
* stores i64 (slot+16 = 0). bad counts slot+16 != expected. */
{ "mixed_variants_in_loop",
"type ft = (i64 | str | bool);\n"
"fn askstr(s: str) ft = { return s; };\n"
"fn aski64(v: i64) ft = { return v; };\n"
"fn main() i32 = {\n"
" let arr: [2]i64 = [10i64, 20i64];\n"
" let i: i32 = 0;\n"
" let bad: i32 = 0;\n"
" for (i < 2) {\n"
" let _v: i64 = arr[i];\n"
" let a: ft;\n"
" let expect: i64 = 0i64;\n"
" if (i == 0) { a = askstr(\"world\"); expect = 5i64; };\n"
" if (i == 1) { a = aski64(99i64); expect = 0i64; };\n"
" let p: u64 = (&a): u64;\n"
" let p2: *i64 = (p + 16u64): *i64;\n"
" let w: i64 = *p2;\n"
" if (w != expect) { bad += 1; };\n"
" i += 1;\n"
" };\n"
" return bad;\n"
"};\n",
0 },
};
static int
run_driver(const char *driver, const struct row *r, int i)
{
char src[64], tmpdir[64], cmd[1024];
snprintf(src, sizeof src, "/tmp/wcrv_%d_%d.ww", getpid(), i);
snprintf(tmpdir, sizeof tmpdir, "/tmp/wcrv_%d_d_%d", getpid(), i);
FILE *f = fopen(src, "wb");
if (!f) return -1;
fputs(r->src, f);
fclose(f);
mkdir(tmpdir, 0755);
snprintf(cmd, sizeof cmd, "cd %s && %s build %s",
tmpdir, driver, src);
if (runwait(cmd) != 0) {
fprintf(stderr, "row[%s]: build via %s failed\n",
r->label, driver);
unlink(src); rmdir(tmpdir);
return -1;
}
const char *base = strrchr(src, '/');
base = base ? base + 1 : src;
char outbin[128];
snprintf(outbin, sizeof outbin, "%s/%s", tmpdir, base);
char *dot = strrchr(outbin, '.');
if (dot && strcmp(dot, ".ww") == 0) *dot = '\0';
int got = runwait(outbin);
unlink(src); unlink(outbin); rmdir(tmpdir);
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, "cgreturn_variant_zero: 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,
"cgreturn_variant_zero[%s][%s]: exit=%d want=%d\n",
drivers[d].name, rows[i].label,
got, rows[i].want);
fail++;
}
}
}
if (fail) {
fprintf(stderr,
"cgreturn_variant_zero: %d/%d fixtures failed\n", fail, total);
return 1;
}
printf("cgreturn_variant_zero: %d/%d ok\n", total, total);
return 0;
}