Skip to content

infer: three fuzzer crashes from #4044 - #4049

Merged
borisbat merged 3 commits into
masterfrom
aleksisch/fuzzer-fixes
Sep 16, 2026
Merged

borisbat merged 3 commits into
masterfrom
aleksisch/fuzzer-fixes

Conversation

@aleksisch

Copy link
Copy Markdown
Collaborator

Fixes #4044 — all three crashes, one fixture each, plus the root cause behind the second one.

1. makeClassRtti null deref

struct A;
class B : A;
class C : B;

class B : A is rejected as it should be, and that rejection returns from ast_structureDeclaration before B gets its generated __rtti. class C : B is then accepted and wrote through a findField("__rtti") that answers null.

makeClassRtti and makeClassFinalize now let the parent lookup pick the branch: a parent that never got the field means the class declares its own. makeClassFinalize had the identical defect one call later and would have been the next crash.

error[30134]: class can only derive from a class, one error, no crash.

2. Null ExprOp2::func in buildAccessFlags

The reported crash is a missing guard, but it was hiding a real inference bug.

Root cause. Four safe_* typeinfo traits returned a replacement node without reportAstChanged()safe_has_field, struct_safe_has_annotation, and both arms of struct_safe_has_annotation_argument. Every sibling branch signals; these four did not. The replacement arrives untyped, the enclosing operator takes its failed to infer early-out, and with no signal that pass is the last one — so ExprOp2::func stays null with no diagnostic.

It broke valid programs, not just fuzzer input:

var b = true
b &&= typeinfo safe_has_field<X>(typeinfo is_ref(1))    // did not compile

Guard. preVisit(ExprOp1*) / preVisit(ExprOp2*) in the access-flag pass now check func the way ExprOp3 and ExprCall already did. buildAccessFlags runs before the side-effect pass that owns the 50613 diagnostic, so without the guard any unsignalled rewrite crashes before it can be reported.

→ the fuzzer input now gives error[30341]: no matching functions or generics: _::&&=(int const, bool const).

A fifth site, is_iterable, looked like the same defect but already signals three lines above its return — left alone.

3. Self-referential fixed-array dimension (OOM, 26.8 GB)

let zz : int[zz][zz]

The dimension expression lives inside the type it dimensions, so giving the dimension's ExprVar the variable's own type gives it a type containing that same expression. With two dimensions each infer pass squares the previous pass's copy — 4^50.

InferTypes::visit(ExprVar*) no longer adopts a global's type while its dimensions are unresolved (isExprType()).

This also covers two shapes the report does not name, both of which OOM'd on master and which the globalVar self-reference guard suggested in the issue would not have caught:

let aa : int[bb][bb]                  // mutual
let bb : int[aa][aa]

let aa : int[length(bb)][length(bb)]  // through a call
let bb : int[length(aa)][length(aa)]

error[30109]: array dimension must be constant, twice, in 0.4s.

Tests

fixture pins
failed_class_parent_rejected.das 1, expect 30134
failed_op2_int_lhs_typeinfo_rhs.das 2, the issue's exact input, expect 30341
failed_global_dim_self_reference.das 3, expect 30109:2
typeinfo_safe_has_field_reinfer.das + 3 siblings one typeinfo branch each
failed_macro_added_op2_must_infer.das op1/op2 with a null func reach the 50613 diagnostic

One shape per file is load-bearing for the four typeinfo fixtures: the trait has to fold in a pass where nothing else changes, and a second shape in the same file signals for the first. My first attempt was a six-arm dastest file and it passed without the fix — a tautology. Negative control: rebuilt with the four reportAstChanged() calls reverted, all four files fail.

Two things that are coverage rather than controls, stated plainly:

  • the ast_unused guard is not reachable by a test once the root cause is fixed — it is consistency with ExprOp3/ExprCall, as the issue asked for
  • failed_macro_added_op2_must_infer.das does not fail without the guard (buildAccessFlags runs before patchAnnotations); it covers two previously untested reportNullFunc sites

Verification

  • tests/language: 1759/1759
  • full --test tests: 14530 tests, 14514 passed, 0 failed, 0 errors, 16 skipped
  • formatter --verify clean over tests/language (410 files); lint clean on the new files; src/ast/REVIEW.das gate OK
  • test_aot_subset builds with the new fixtures; the failed_* files are excluded by the existing filter at tests/aot/CMakeLists.txt:334, so no CMake change

🤖 Generated with Claude Code

@borisbat borisbat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mainly why is type-trait an ast-change?

@@ -2716,6 +2716,7 @@ namespace das {
return new ExprConstBool(expr->at, ft != nullptr);
} else {
if (expr->trait == "safe_has_field") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how so? why is this an AST change??

} else {
if (expr->trait == "struct_safe_has_annotation") {
reportAstChanged();
return new ExprConstBool(expr->at, false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

if (it == ann.end()) {
if (expr->trait == "struct_safe_has_annotation_argument") {
reportAstChanged();
return new ExprConstBool(expr->at, false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same again. why is this an ast change?

`struct A; class B : A; class C : B;` crashed in makeClassRtti. The
`class B : A` error is correct, and it returns from
ast_structureDeclaration before B gets its generated __rtti, so the
accepted `class C : B` wrote through a findField("__rtti") answering
null. makeClassFinalize had the identical defect one call later and
would have been the next crash.

Both now let the parent lookup pick the branch: a parent that never got
the field means the class declares its own.
@aleksisch
aleksisch force-pushed the aleksisch/fuzzer-fixes branch 3 times, most recently from 5f1439e to e35cade Compare September 16, 2026 21:56
@aleksisch
aleksisch marked this pull request as ready for review September 16, 2026 21:57
`1 &&= typeinfo safe_has_field<var48>(typeinfo is_ref(1))` crashed in
buildAccessFlags on a null ExprOp2::func. The missing guard is real, but
it was covering an inference bug.

Root cause: four safe_* typeinfo traits returned a replacement node
without reportAstChanged - safe_has_field, struct_safe_has_annotation,
and both arms of struct_safe_has_annotation_argument. Every sibling
branch signals; these four did not. The replacement arrives untyped, the
enclosing operator takes its "failed to infer" early-out, and with no
signal that pass is the last one, so func stays null with no diagnostic -
which is also why the reported input never reached operator resolution
and never got its 30341. It rejected valid code too:
`b &&= typeinfo safe_has_field<X>(typeinfo is_ref(1))` did not compile.

Guard: preVisit of ExprOp1, ExprOp2 and ExprOp3 in the access-flag pass
now return early on a null func, the way ExprCall right below them
already did. buildAccessFlags runs before the pass that owns the 50613
diagnostic, so without it any unsignalled rewrite crashes before it can
be reported.

A fifth site, is_iterable, looked like the same defect but already
signals three lines above its return - left alone.

The fixture holds all four traits and the reported input, and must stay
free of anything that keeps signalling - a print's string builder is
enough, and it grants the enclosing operator a repair pass that hides the
defect. Reverting the four calls turns the expected 30341 into 50613
five times over.
`let zz : int[zz][zz]` ate 26.8 GB and died to the OOM killer. The
dimension expression lives inside the type it dimensions, so giving the
dimension's ExprVar the variable's own type gives it a type containing
that same expression - with two dimensions each infer pass squares the
previous pass's copy, 4^50.

InferTypes::visit(ExprVar*) no longer adopts a global's type while its
dimensions are unresolved (isExprType).

This also covers two shapes the report does not name, both of which OOM
on master and neither of which the globalVar self-reference guard the
issue suggests would catch:

    let aa : int[bb][bb]                  // mutual
    let bb : int[aa][aa]

    let aa : int[length(bb)][length(bb)]  // through a call
    let bb : int[length(aa)][length(aa)]

All of them now report error[30109] in under half a second. A forward
reference that resolves in a later pass (`let b : int[MM]` before
`let MM = 3`, and a function reading b) keeps working.
@aleksisch
aleksisch force-pushed the aleksisch/fuzzer-fixes branch from e35cade to 73c420f Compare September 16, 2026 22:18
@borisbat
borisbat merged commit 033a14b into master Sep 16, 2026
33 checks passed
@borisbat
borisbat deleted the aleksisch/fuzzer-fixes branch September 16, 2026 22:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fuzzer crashes (3)

2 participants