From 3d7973596422f1c9e2275016a5f264a2ae504ceb Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Mon, 29 Jun 2026 15:49:01 +0900 Subject: [PATCH] check: reject a deref-less multi-level **fn call, not silently miscompile (#14) wwstage exprtype's N_CALL fn-value arm peeled ALL pointer levels before the TY_FN gate, so a deref-less `pf(21)` where pf:**fn type-checked and lowered -- then segfaulted at runtime (a silent miscompile). cstage peels exactly one level and loud-rejects (the C6a discipline, check.c:1947). Align wwstage DOWN: peel one level (loop -> if); a remaining non-TY_FN callee hits a loud "calling non-function" reject mirroring cstage's message. Both stages now reject the exotic deref-less `**fn`/`***fn` shape; the legitimate `(*pf)(21)` and one-level deref-less `f(21)` (f:*fn) still compile + run. Multi-level autoderef is a separate deferred FEATURE, not a miscompile to lower (rule 7/10 -- align the richer stage down to the leaner, no value ships). ww-only change (cstage is the correct oracle); a reject emits no asm, so the byte-id baselines and LANGBYTEID floor are unchanged. Pins: cfail test/wcc/data/fnptr_pp_derefless_reject (both stages reject, reddens-on-revert -- the silent miscompile resurfaces if the fix is reverted) + test/lang/fnptr_derefless_call_test (positive guard (*pf)(21)==42 and one-level f(21), value-asserted + byte-id, so the fix does not over-reject the legitimate one-level autoderef). --- selfhost/cmd/wcc/check.ww | 22 +++++++++++++- test/lang/fnptr_derefless_call_test.ww | 30 ++++++++++++++----- .../data/fnptr_pp_derefless_reject/case.ww | 21 +++++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 test/wcc/data/fnptr_pp_derefless_reject/case.ww diff --git a/selfhost/cmd/wcc/check.ww b/selfhost/cmd/wcc/check.ww index 63a98e77..f99c81af 100644 --- a/selfhost/cmd/wcc/check.ww +++ b/selfhost/cmd/wcc/check.ww @@ -3760,7 +3760,14 @@ fn exprtype(c: *checker, e: *syntax.node, hint: *syntax.node) *syntax.node = { // already carries its RETURN type (fn-decl.lhs), not its fn-type — // so a `fn make() fn() void` callee would otherwise mis-yield void. let ct: *syntax.node = resolvealias(c, unwrapbang(exprtype(c, callee, nil))); - for (ct != nil && ct.kind == syntax.nkind.N_TPTR) { + // #14/#181-cgen: ONE pointer-peel only, mirroring cstage + // cmd/wcc/check.c:1947. #181-cgen lowers an indirect call by using the + // callee VALUE as the target, the fn address for a single `*fn` but only + // the *address of* the fn-ptr for `**fn` — so a deref-less `**fn` call + // peeled past one level ships a CALL through a fn-ptr address (segfault). + // Multi-level fn-ptr autoderef is a deferred FEATURE (#181); the + // unsupported shape stays a loud reject in BOTH stages (rule 7/10). + if (ct != nil && ct.kind == syntax.nkind.N_TPTR) { ct = resolvealias(c, unwrapbang(ct.lhs)); }; if (ct != nil) { if (ct.kind == syntax.nkind.N_TFN) { @@ -3768,6 +3775,19 @@ fn exprtype(c: *checker, e: *syntax.node, hint: *syntax.node) *syntax.node = { e.type_ = tinfofornode(c, res): *void; return res; }; }; + // Mirror cstage's loud `n->type = err(c, ..., "calling non-function")` + // (check.c:1948): a callee that isn't a TY_FN after the single peel is a + // hard reject. The tyerr stamp doubles as a once-guard — exprtype is not + // memoized (unlike cstage's cexpr n->type cache), so a second pass over + // this N_CALL must not re-emit; it also suppresses the post-checker + // asserttyped gate (L6683). One diagnostic, cgen gated off (w6c L186). + if (e.type_ == nil) { + cerr(e.file); cerr(":"); + cerr(strconv.i32tos(e.line, strconv.base.DEC)); + cerr(": error: calling non-function\n"); + c.errs += 1; + e.type_ = c.tc.tyerr: *void; + }; return nil; }; if (k == syntax.nkind.N_DOT) { diff --git a/test/lang/fnptr_derefless_call_test.ww b/test/lang/fnptr_derefless_call_test.ww index 1c99cd65..ede65257 100644 --- a/test/lang/fnptr_derefless_call_test.ww +++ b/test/lang/fnptr_derefless_call_test.ww @@ -14,13 +14,19 @@ // non-function"), so this whole file fails to build in the cstage `ww // test` / byte-id legs — a hard red, not a silent value drift. // -// NOT covered here: a `**fn` deref-less call (`let pf: **fn(...) = &f; -// pf(21)`). The #181-cgen path uses the callee VALUE as the call target, -// so it autodereferences exactly ONE level; a `**fn` deref-less call -// drops a `MOVQ (AX),AX` and miscompiles on BOTH stages (byte-id-blind, -// returns garbage). cstage deliberately stays loud on `**fn` (one-level -// peel); multi-level fn-ptr autoderef is deferred (#181). The explicit -// `(**pf)(21)` is correct (cgexpr emits both loads). +// The #181-cgen path uses the callee VALUE as the call target, so it +// autodereferences exactly ONE level: a `*fn` callee is the fn address, but +// a `**fn` callee is only the *address of* the fn-ptr. So a deref-less +// `**fn` call (`let pf: **fn(...) = &f; pf(21)`) drops a `MOVQ (AX),AX` and +// is a hard reject in BOTH stages — cstage at cmd/wcc/check.c:1947 (one-level +// peel), wwstage aligned DOWN at selfhost/cmd/wcc/check.ww (task #14, was the +// SILENT-miscompile side). That loud reject is pinned in +// test/wcc/data/fnptr_pp_derefless_reject. The LEGAL one-explicit-deref +// `(*pf)(21)` (which yields a `*fn`, then autoderefs the remaining single +// level) is covered by derefless_pp_one_explicit_deref below — the shape the +// one-level peel must NOT over-reject. Multi-level fn-ptr autoderef is a +// deferred FEATURE (#181); the explicit `(**pf)(21)` is correct (cgexpr emits +// both loads). package fnptr_derefless_call_test; @@ -61,3 +67,13 @@ fn pair(x: i32) (i32, i32) = { return (x, x + 1); }; let a, b = f(7); assert(a + b == 15); }; + +@test fn derefless_pp_one_explicit_deref() void = { + // #14 positive guard: a `**fn` with ONE explicit deref `(*pf)` yields a + // `*fn`, whose call autodereferences the remaining single level — legal, + // the shape the one-level peel must NOT over-reject. Its deref-less twin + // `pf(21)` is the loud reject pinned in fnptr_pp_derefless_reject. + let f: *fn(x: i32) i32 = &add1; + let pf: **fn(x: i32) i32 = &f; + assert((*pf)(21) == 42); +}; diff --git a/test/wcc/data/fnptr_pp_derefless_reject/case.ww b/test/wcc/data/fnptr_pp_derefless_reject/case.ww new file mode 100644 index 00000000..8b61bd01 --- /dev/null +++ b/test/wcc/data/fnptr_pp_derefless_reject/case.ww @@ -0,0 +1,21 @@ +//ww:error "calling non-function" +// #14: a deref-less call through a pointer-to-pointer-to-fn (`pf(21)` where +// pf: **fn) is a hard reject in BOTH stages. The #181-cgen path lowers an +// indirect call by using the callee VALUE as the target, so it autoderefs +// exactly ONE pointer level — correct for a single `*fn`, but for a `**fn` +// the value is only the *address of* the fn-ptr, so a deref-less call ships +// a CALL through that address and segfaults. cstage rejected at one-level +// peel (cmd/wcc/check.c:1947); wwstage was the SILENT-miscompile side until +// it was aligned DOWN to the same one-level peel (selfhost/cmd/wcc/check.ww, +// task #14). Multi-level fn-ptr autoderef is a deferred FEATURE (#181); the +// legal one-explicit-deref `(*pf)(21)` stays accepted (test/lang/ +// fnptr_derefless_call_test.ww derefless_pp_one_explicit_deref). REVERT +// MANIFESTATION: revert the wwstage peel loop->if and this case COMPILES +// again under w6c_ww (the runww ERROR arm runs both stages). +package main; +fn add1(x: i32) i32 = { return x + 21; }; +export fn main() i32 = { + let f: *fn(x: i32) i32 = &add1; + let pf: **fn(x: i32) i32 = &f; + return pf(21); +};