fix fuzzer-found compiler bugs (#3858) and arithmetic divergences - #3671
Merged
Conversation
aleksisch
force-pushed
the
aleksisch/fix-arithmetic-divergences
branch
7 times, most recently
from
August 27, 2026 06:25
2300ada to
4573191
Compare
Contributor
There was a problem hiding this comment.
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.0in interp/fold (SSEv_absreturned-0.0via 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 guardsINT_MIN / -1/INT_MIN % -1to match the interpreter (codegen version bumped 0x54→0x55). - AOT control flow out of
try/recover(#3858): the emitter now tracks loop/try nesting and rewritesbreak/continue/returnthat cross adas_try_recoverlambda boundary into a flag-and-dispatch scheme; also fixes ref make-local, handled-result lambda ascend, andassumestring-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,gotoout of a captured block, float16 table-key literals) and thetests/aotdefault-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 on lines
+2256
to
+2257
| if (at >= 0 && flow.stack[at].slot != "") { | ||
| write(*ss, "{flow.stack[at].slot} = {flow.ret_ptr ? "&(" : ""}"); |
aleksisch
force-pushed
the
aleksisch/fix-arithmetic-divergences
branch
2 times, most recently
from
August 27, 2026 17:52
d3cfeda to
20f7a82
Compare
aleksisch
marked this pull request as ready for review
August 27, 2026 17:53
aleksisch
force-pushed
the
aleksisch/fix-arithmetic-divergences
branch
5 times, most recently
from
August 28, 2026 06:18
127c8dc to
bd7fedf
Compare
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
force-pushed
the
aleksisch/fix-arithmetic-divergences
branch
from
August 28, 2026 06:35
bd7fedf to
151a011
Compare
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>
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
force-pushed
the
aleksisch/fix-arithmetic-divergences
branch
from
August 28, 2026 08:58
151a011 to
05cc2cd
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes fuzzer-found bugs, one commit per bug. Covers issue #3858 and a few arithmetic divergences.
abs(-0.0)returns+0.0; float->half converts subnormals instead of flushing to zeroINT_MIN / -1(LLVM sdiv poison)autois not an alias lookup; comprehension skeletonforhas no body to unwrapfordoes not lower while its subtree is still uninferredEach fix comes with a test (dastest fixture and/or AOT-compiled arm).
🤖 Generated with Claude Code