Skip to content

fix fuzzer-found compiler bugs (#3858) and arithmetic divergences - #3671

Merged
borisbat merged 14 commits into
masterfrom
aleksisch/fix-arithmetic-divergences
Aug 28, 2026
Merged

fix fuzzer-found compiler bugs (#3858) and arithmetic divergences#3671
borisbat merged 14 commits into
masterfrom
aleksisch/fix-arithmetic-divergences

Conversation

@aleksisch

@aleksisch aleksisch commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes fuzzer-found bugs, one commit per bug. Covers issue #3858 and a few arithmetic divergences.

  • math: abs(-0.0) returns +0.0; float->half converts subnormals instead of flushing to zero
  • jit: guard INT_MIN / -1 (LLVM sdiv poison)
  • aot: break/continue/return out of try/recover; ref-typed make-local temp; handle ascend gate; assume subexpression walk
  • infer: goto out of a captured block is a compile error; struct alias loop checks; hashable table key check; unnamed auto is not an alias lookup; comprehension skeleton for has no body to unwrap
  • generate: a for does not lower while its subtree is still uninferred

Each fix comes with a test (dastest fixture and/or AOT-compiled arm).

🤖 Generated with Claude Code

@aleksisch
aleksisch force-pushed the aleksisch/fix-arithmetic-divergences branch 7 times, most recently from 2300ada to 4573191 Compare August 27, 2026 06:25
@borisbat
borisbat requested a balanced review from Copilot August 27, 2026 16:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a batch of fuzzer-discovered divergences where daslang's three execution tiers (interpreter, const-folding, JIT) disagreed with each other or where the AOT C++ emitter produced code that failed to compile. It spans arithmetic semantics, JIT codegen, the AOT emitter, compiler infer-time crashes, and an unbounded compile-time-evaluation hang.

Changes:

  • Arithmetic parity: abs(-0.0) now returns +0.0 in interp/fold (SSE v_abs returned -0.0 via MAXPS); half(x) no longer flushes subnormals to zero (the subnormal shift double-counted the 23→10 mantissa reduction and hit UB); the JIT now guards INT_MIN / -1 / INT_MIN % -1 to match the interpreter (codegen version bumped 0x54→0x55).
  • AOT control flow out of try/recover (#3858): the emitter now tracks loop/try nesting and rewrites break/continue/return that cross a das_try_recover lambda boundary into a flag-and-dispatch scheme; also fixes ref make-local, handled-result lambda ascend, and assume string-builder AOT crashes.
  • Compile-time safety (#3858): adds max_run_iterations (default 1000000) bounding loop iterations per compile-time-evaluated call; plus several infer-time crash fixes (self-referential struct typedefs, generator-literal array dims, goto out of a captured block, float16 table-key literals) and the tests/aot default-ctor fixture link fix.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
include/daScript/simulate/sim_policy.h Correct Abs for double/float/vec4f to clear the sign bit rather than use max/ternary
include/daScript/misc/vectypes.h Fix half-subnormal conversion shift (removes double-count and UB)
modules/dasLLVM/daslib/llvm_jit.das Emit INT_MIN/-1 division-overflow guard in the JIT
modules/dasLLVM/daslib/llvm_jit_run.das Bump LLVM_JIT_CODEGEN_VERSION to invalidate cached DLLs
daslib/aot_cpp.das Try/recover exit rewriting; ref make-local, handled-result, assume fixes
src/ast/ast_simulate.cpp Insert per-loop folding-budget node in the folding context only
src/ast/ast_const_folding.cpp Reset foldingBudget before each compile-time evaluation
src/ast/ast_infer_type.cpp Block-literal dims, structure alias-loop, goto-out-of-block checks
src/ast/ast_infer_type_helper.cpp typeMacro alias-loop, table-key hashability, non-int dim checks
include/daScript/simulate/code_of_policies.h, include/daScript/ast/ast.h New max_run_iterations policy + foldingBudget state
src/builtin/module_builtin_rtti.cpp, module_builtin_ast_serialize.cpp Bind/serialize max_run_iterations
tests/…, tests/aot/CMakeLists.txt New regression tests + AOT module fixture registration
doc/source/… Document max_run_iterations

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread daslib/aot_cpp.das
Comment on lines +2256 to +2257
if (at >= 0 && flow.stack[at].slot != "") {
write(*ss, "{flow.stack[at].slot} = {flow.ret_ptr ? "&(" : ""}");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added new test

@aleksisch
aleksisch force-pushed the aleksisch/fix-arithmetic-divergences branch 2 times, most recently from d3cfeda to 20f7a82 Compare August 27, 2026 17:52
@aleksisch
aleksisch marked this pull request as ready for review August 27, 2026 17:53
@aleksisch aleksisch changed the title [wip] fix arithmetic divergences between interpreter, const folding and JIT fix fuzzer-found compiler bugs (#3858) and arithmetic divergences Aug 27, 2026
@aleksisch
aleksisch force-pushed the aleksisch/fix-arithmetic-divergences branch 5 times, most recently from 127c8dc to bd7fedf Compare August 28, 2026 06:18
aleksisch and others added 7 commits August 28, 2026 09:28
SimPolicy_MathTT::Abs returns the argument unchanged for -0.0, and v_abs on
x86 is max(-a, a), which hands back -0.0 as well. The JIT emits fabs and was
already right, so this was a tier divergence; const folding evaluates these
policies, so the folded form was wrong in both tiers. The vector fix clears
the sign bit directly - include/vecmath is vendored and stays untouched.
The interpreter throws "division overflow" for INT_MIN / -1 and answers 0 for
INT_MIN % -1; the JIT emitted a raw sdiv/srem, which LLVM leaves poison. The
division now throws like the interpreter, and the modulo selects a divisor of
1, since INT_MIN % 1 is 0 - the same answer. Signed scalars only: the
interpreter does not guard vector division either, and int8/int16 have no
division at all. Repins LLVM_JIT_EMITTER_HASH.

The interpreter's DAS_FAST_INTEGER_MOD trick had the same divergence in
disguise: int32_t(A/B) on the 2^31 quotient truncates to INT_MIN on x86
and saturates to INT_MAX on arm64. Converting the quotient through
int64_t keeps every step exact on both, with no extra branch.
das_float_to_float16's subnormal shift double-counted the 23->10 mantissa
reduction, flushing every subnormal to zero; for the smallest inputs it
reached 38 - UB, the source of the -nan. Hits any half(x) below the half
min normal, 6.104e-5. The right shift is
m = base >> (126 - e): base is 1.f << 23, and a half subnormal is m * 2^-24.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The try and recover bodies are C++ lambdas passed to das_try_recover, so
an exit aimed outside them stayed inside: break/continue did not compile
("'break' statement not in loop or switch statement"), and a return only
left the lambda, silently answering differently than the interpreter --
tests/language/div_by_zero.das has that shape.

Each body is scanned for the exits which really leave it; those set a
control flag, and the dispatch after das_try_recover turns it back into
the real exit -- itself translated, so nesting chains outwards. A returned
value rides a slot typed by the function result.

The fixtures divide through a global so RunFolding does not fold the calls
away and the AOT'd bodies actually run.

Fixes #3858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The temporary a make-local writes into is returned BY reference, but its
declaration went through describeVarLocalCppType, which substitutes a
reference with a pointer. A make-local of a reference type therefore
declared `Func *` while the lambda around it returned `Func &`:

    error: non-const lvalue reference to type 'Func' cannot bind to a
           value of unrelated type 'Func *'

Declare the value for a ref result, which is what the return binds to.
Nothing else in the tree has a ref-typed make-local temp.

Fixes #3858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ascend emitter picked das_ascend_handle from firstType alone. For a
pointer that is the pointee, but for a lambda or block it is the RESULT
type, so a lambda returning a C++-bound type took the handle path and
instantiated a template with no definition:

    error: implicit instantiation of undefined template
           'das::das_ascend_handle<false, das::Lambda>'

Gate on the ascended type being a pointer as well. A handled result is an
ordinary capture-frame allocation, same as any other result type. No
ascend anywhere else in the tree changes.

Fixes #3858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
findLabel walked every enclosing scope, so a goto inside a captured block
resolved against a label in the enclosing function. Nothing downstream can
honour that: AOT emits `goto label_50` into the C++ lambda the block became
("use of undeclared label"), the JIT fails to simulate (50503), and the
interpreter itself is inconsistent -- it jumps when the label has statements
after it and throws "jump to label N failed" when it does not.

Stop the search at a closure, which is the rule 'break' and 'continue'
already follow (30125 / 30126). The existing "can't find label" diagnostic
then reports it, at the goto rather than at the block.

Fixes #3858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aleksisch
aleksisch force-pushed the aleksisch/fix-arithmetic-divergences branch from bd7fedf to 151a011 Compare August 28, 2026 06:35
aleksisch and others added 3 commits August 28, 2026 11:54
An assume keeps its subexpression as a template for the use sites and
generates no code of its own -- the emitter already wraps it in `#if 0`.
It still descended into it, and asked for type info the nodes there do
not carry, so `-aot` on an assume bound to an interpolated string crashed
in makeTypeInfo. Only with the optimizer off: otherwise the builder folds
away before AOT sees it.

The #if 0 region is labeled with the assume's name and expression -
describe renders arbitrary text, so it stays behind the preprocessor
rather than in a bare line comment.

Return false from canVisitWithAliasSubexpression, which is what the lint
and verify visitors already do for the same reason.

Fixes #3858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

The jit side had the same walk: ResolveExternVisitor, DisableJitVisitor and llvm_exe's
CollectExternVisitor descend into the assume subexpression and touch
an ExprOp2 whose func was never inferred. Both get the same
canVisitWithAliasSubexpression override the jit codegen visitor
already carries.
An alias is structural, so `typedef T = array<T>` has no finite expansion.
Top level says so already (31106), but a structure alias is walked by
Program::visitStructure through preVisitStructureAlias / visitStructureAlias,
neither of which InferTypes overrode -- so the check in visitAlias never ran
on one, and struct and class aliases accepted the impossible type silently.
The nominal spelling `struct S { x : array<S> }` is unaffected: S is a name,
not an expansion.

isLoop also learns to look inside typeMacroExpr, which is where a typeMacro
keeps its arguments -- `typedef T = Unknown<T>` at top level now reports the
loop rather than the missing macro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The loop check had to move ahead of the walk. Program::visitStructure calls
preVisitStructureAlias and then descends into every alias unconditionally,
so reporting from the pre-visit did not stop the descent -- and the descent
is what overflowed the stack on an alias which expands into itself
(TypeDecl::visit / ExprTypeDecl::visit, ~88k frames).

canVisitStructure runs it instead, where returning false skips the walk in
the same pass. `struct S { typedef T = Unknown<T>; }` now reports the loop
rather than crashing the compiler.

Fixes #3858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aleksisch and others added 4 commits August 28, 2026 11:54
isTableKeyType rejects the 16/8-bit lattice on purpose - the runtime table
key machinery has no rows for it, so it is meant to be a clean compile
error. inferGenericType checks it on its table branch, but a table literal
lowers to to_table_move(tuple<keyT;valT>[...]): the key binds from a tuple,
that branch never runs, and the table type is built afterwards in
inferAlias with no check at all.

So `{0h => 1}` produced a table<float16;int> whose key reached
makeValueNode - the assert in Debug, a SIGSEGV in Release. The same crash
on any use of such a literal, returned or not.

Check the key where the table type is substituted. The existing 30254
("table key has to be declared as a basic 'hashable' type") then reports it.

Fixes #3858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`type<...>` marks its payload autoToAlias, so named autos inside a tag
participate in alias binding, and the flag inherits down the payload's
subtree. An ANONYMOUS auto reaching that mode was looked up by its name -
the empty string - and InferTypes::findAlias scans global variables' types,
where TypeDecl::findAlias matches `alias == name` without excluding "":
the first type node with an empty alias field wins. For

    variant var9{}
    var v : var9[ generator < type < lambda <>>>{} ] ;

the anonymous auto inside `lambda<>` was substituted with v's own type,
dimension expression included. A type that contains itself through its own
dimension doubles on every inference pass - each type copy re-clones the
dimension expression, which contains the type - until a recursive walk runs
out of stack around pass 40. An unnamed auto has nothing to look up by
name, whatever the mode; with the lookup gone, the dimension converges to
plain 30109 like any other non-constant.

Fixes #3858

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RenameVar walks a for's iterator NAMES, which the parser fills, and indexes
iteratorVariables, which infer fills, with the same index. A nested for that
is not inferred yet has names but no variables, so the rename read past the
empty vector and crashed on the garbage pointer. Source-reachable: an
iterator comprehension lowers while a for inside an interpolated string in
its source is still uninferred.

Rather than renaming a partial set, the rename reports the subtree not
ready and replaceGeneratorFor declines the whole lowering for this pass -
generation waits for inference, retrying once the inner for is inferred, or
surfacing the real errors if it never is.

Fixes #3858

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An ExprArrayComprehension keeps its body in subexpr; the exprFor it
carries has body == null by design. When the comprehension's source
never infers (an empty [] literal), the comprehension never lowers,
and its skeleton for reaches the generator unwrap path inside a
generator function, which read expr->body->getEvalFlags() unguarded.
A for with no body has nothing to yield, so it never unwraps.

Fuzzer find, issue #3858.
@aleksisch
aleksisch force-pushed the aleksisch/fix-arithmetic-divergences branch from 151a011 to 05cc2cd Compare August 28, 2026 08:58
@borisbat
borisbat merged commit 6dbf62f into master Aug 28, 2026
37 checks passed
@borisbat
borisbat deleted the aleksisch/fix-arithmetic-divergences branch August 28, 2026 11:59
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.

3 participants