JIT withholds readonly on a const parameter the body writes through; LINT031 rejects the const-strip write (#3991) - #3996
Merged
Conversation
…ly when the compiler's write analysis left no access_ref mark on it - the same per-argument mark the DCE side trusts since the forwarded const-stripped write fix - so a body that reinterprets the const away and stores, hands the pointer to memcpy, or takes a const structure's address and casts it back no longer emits a module whose definition stores through a parameter its declaration calls read-only; from --jit-opt-level=1 up the optimizer folded the caller's read of that memory to its pre-call value while the interpreter and AOT were right (#3991). LLVM_JIT_CODEGEN_VERSION 0x7e and the emitter pin re-hashed; tests/jit_tests/const_arg_readonly.das pins the void? out-parameter, the same-type pointer, the memcpy destination, the const reference, a noalias-hinted const pointer, the var control and a cross-partition callee, with the guard removed five of seven fail at O3. LINT031 makes the shape loud, an error on every tree: a const pointer or reference parameter the body writes through after stripping the const - a reinterpret, an intptr round trip, pointer arithmetic, or a call slot the callee writes - anchored at the parameter, the line the fix rewrites. The rule walks the write target to its root rather than reading the compiler's marks, which addr() and a reassigned pointer copy set alike: through carriers (casts, ref-to-value, addr, null coalesce, intptr, a first-argument-aliasing call) and into operands (deref, field, index, swizzle), following a local pointer copy to its initializer only when the write lands in what it holds; below the target a node yielding a pointer or an integer is a loaded address and ends the chase - LLVM's readonly is about memory based on the argument, not memory an address stored there names. A call slot writes when the callee's own body does, judged by the same walk through Lint031BodyScan and memoized per (mangled-name hash, slot); a builtin answers by name, since memcpy and the memset family register void* on both sides and the compiler marks every pointer a modifying builtin sees, temp_array's view pointer included. An implicit parameter type waives the contract and is skipped. The fixture utils/lint/tests/lint031_const_strip_write.das pins eight positives beside the negatives: a var parameter, a strip that only reads, an address taken to read, a struct copied by value, a reassigned pointer copy, a stored address written through. The sweep the rule armed: llvm_jit.das's two make-index visitor overrides and coverage.das's function-body hook take var on the node they annotate in place, tests/jit_tests/memset.das's wrappers and dasLLAMA's par_memcpy take var on their destination, and the KV readback hook - RdecReadKvFn, RdecReadKvBulkFn, their unset stubs, the two forwarders, the Vulkan implementations and the tier test's doubles - declares the pointers it fills var, and the tier test's install arm reads back through the two forwarders and checks the doubles' sentinels landed in the caller's rows; the forwarded-write fixture (tests/language/const_strip_write_through_passthrough.das) and the new JIT test carry options _nolint = "LINT031", the shape being their subject. lint.rst documents LINT031 and stops calling a const-stripping reinterpret good under LINT005; ARCHITECTURE_LINT.md carries the walk; CLAUDE.md's fails-silently bullet and the language skill's memory and functions references say the type is what the optimizer reads. The review round's rulings: the callee-body memos - LINT030's and LINT031's - are cleared when a program's visit starts, since the runner, the MCP server and the LSP keep one process across programs and an edited body keeps its mangled name; the chase runs before a callee scan, so a body is read only for an argument rooted at a const parameter; the intptr and memory-builtin names are matched on the root generic, walked to the end of the chain; the fixture pins a das callee whose var slot writes and one whose var slot only reassigns its copy, and a memset destination beside memcpy's; ARCHITECTURE_LINT.md records the builtin table's C++ mirror pair, and daslib/REVIEW.md's fixture rule names the fixture directory. The TDD round's additions: the chase's dead arms went (a safe field, an as-variant, a safe index, a null coalesce, an operator on pointers - pointer arithmetic is a call after inference), a deref that yields an integer is the pointee and continues where a field or index that yields one is a loaded address and stops, and the fixture pins the rest - a reference local, a swizzle, a step, a clone and a move at the top level and in a callee, a callee's nested memcpy, a recursive callee (the memo's mid-scan seed keeps the scan finite), two writes reporting once, a self-stripping callee reported on its own line and not at its caller, the memcpy source slot, an implicit parameter, a by-value parameter's own slot and a pointer field loaded from the pointee; tests/lint/test_lint031_memo_and_anchor.das compiles two programs in one process to pin the parameter anchor and the per-program memo. utils/lint/tests/lint018_mem_size_narrowing.das's memcpy destinations take var, the rule having found them too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
It changes LLVM JIT attribute semantics and introduces a new default-on lint rule with nontrivial AST-chase/callee-analysis logic, warranting final human review despite strong test coverage.
Pull request overview
This PR fixes an LLVM JIT miscompilation where const pointer/reference parameters were always marked readonly based on their declared type, even when the function body writes through them after stripping const. It also introduces a new default-on lint rule (LINT031) to reject that unsafe “const-strip write” pattern at the source level, pushing code toward declaring such parameters var.
Changes:
- Update the LLVM JIT parameter-attribute logic to withhold
readonlywhen the compiler marked an argument as written (access_ref), and bump JIT cache version/hash to invalidate cached DLLs. - Add LINT031 to
daslib/lintto detect const-stripped writes (including viaintptrlaundering and write-through call slots), plus documentation updates for the new rule. - Update identified call sites/tests/fixtures to use
varparameters where the body writes through the parameter, and add targeted tests for both the lint behavior and the JIT regression.
File summaries
| File | Description |
|---|---|
| utils/lint/tests/lint031_const_strip_write.das | New lint fixture covering positive/negative shapes for LINT031 target-chase logic. |
| utils/lint/tests/lint018_mem_size_narrowing.das | Adjust memcpy wrapper params to var to avoid const-strip-write violations under new lint. |
| tests/lint/test_lint031_memo_and_anchor.das | New tests to pin LINT031 anchoring and per-program memoization behavior. |
| tests/lint/_fixture_lint031_writer.das | New fixture program whose callee writes through its slot (used by LINT031 memo tests). |
| tests/lint/_fixture_lint031_reader.das | New fixture program with same callee name but read-only body (validates memo reset). |
| tests/language/const_strip_write_through_passthrough.das | Suppress LINT031 for a language regression test whose purpose is the forbidden shape. |
| tests/jit_tests/memset.das | Make memset wrappers’ destination pointers var to reflect write-through semantics. |
| tests/jit_tests/const_arg_readonly.das | New JIT regression test reproducing the readonly-miscompile scenarios (and control). |
| tests/jit_tests/_const_arg_readonly_writer.das | New helper module to force cross-partition call behavior in the JIT test. |
| skills/daslang/references/types.md | Align type-system guidance to the “don’t strip const to write; declare var” rule. |
| skills/daslang/references/memory.md | Strengthen the memory-model warning: const-strip writes can vanish under optimization/JIT. |
| skills/daslang/references/functions.md | Update function-parameter guidance to reflect the new const-strip-write hazard and remedy. |
| modules/dasLLVM/daslib/llvm_jit.das | Withhold LLVM readonly on const indirect params when access_ref says the body writes. |
| modules/dasLLVM/daslib/llvm_jit_run.das | Update emitter hash used by C++ pinning test for JIT emitter sources. |
| modules/dasLLVM/daslib/llvm_jit_plan.das | Bump LLVM_JIT_CODEGEN_VERSION to invalidate cached JIT DLLs after semantic change. |
| modules/dasLLAMA/tests/test_gpu_tier.das | Expand GPU-tier tests to validate KV readback forwarders write through pointers (now var). |
| modules/dasLLAMA/dasllama/dasllama_vulkan_decode.das | Mark KV readback pointer params var to match write-through behavior. |
| modules/dasLLAMA/dasllama/dasllama_vulkan_common.das | Make par_memcpy destination param var to reflect write-through semantics and satisfy lint. |
| modules/dasLLAMA/dasllama/dasllama_gpu_tier.das | Update readback function typedefs and forwarders to use var pointer params (API contract). |
| doc/source/reference/language/lint.rst | Document LINT031 and clarify interaction with existing LINT005 “reinterpret” exemptions. |
| daslib/REVIEW.md | Clarify where lint rule fixtures must be updated (utils/lint/tests/). |
| daslib/lint.das | Implement LINT031: root-chase for write targets, callee write-through detection, and memo reset. |
| daslib/coverage.das | Adjust visitor override param mutability (var expr) to match body mutation patterns under lint. |
| daslib/ARCHITECTURE_LINT.md | Add detailed architecture notes for LINT031’s chase/memo strategy and builtin writer set. |
| CLAUDE.md | Update “fails silently” guidance to include intptr laundering and explicitly reference LINT031. |
Review details
- Files reviewed: 25/25 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
Behavior change:
daslib/lintnow rejects a const pointer or reference parameter the body writes through (LINT031, on by default) - a project that lints such code must declare the parametervar.Why. The JIT gave every const pointer or reference parameter the LLVM
readonlyattribute from its declared type alone. A body that stripped the const and wrote anyway (areinterpret, anintptrround trip, amemcpydestination) was miscompiled from--jit-opt-level=1up: the caller read the pre-call value. The interpreter and AOT were correct. Reported as #3991.What changes.
readonlywhen the compiler's per-argument write mark is set, the mark the DCE side already trusts; codegen version bumped, emitter pin re-hashed.var: two JIT visitor overrides, the coverage body hook, the memset test wrappers, a lint fixture's memcpy destinations, dasLLAMA'spar_memcpy, and the KV readback hook family (typedefs, stubs, forwarders, Vulkan implementations, test doubles).Observable behavior.
--jit-opt-level>=1, const parameter written through after a strip: caller reads0-> caller reads the stored value.error[50503]: LINT031at the parameter.RdecReadKvFn/RdecReadKvBulkFn: an out-of-tree implementer with constkp/vpno longer matches the typedef.Where to look.
apply_impl_param_attrsinmodules/dasLLVM/daslib/llvm_jit.das(one condition);lint031_rootandlint031_callee_writesindaslib/lint.das(the chase and the loaded-address stop are the risky parts).Validation, claims, ledger
Validation
trueand the cache key bumped,tests/jit_tests/const_arg_readonly.dasfails 5 of 7 arms under-jitat O3; with the guard, 8/8.bin/daslang -jit dastest/dastest.das -- --timing-outliers 10 --test modules/dasLLVM/tests: 110/110 on this LLVM-enabled build, after the emitter edit.modules/dasLLAMA/tests/test_gpu_tier.dasunder-jit: 12/12, the new arm reading back through both forwarders.run.das -- --suite model-free: 72 of 74 files green; the two red ones (test_vulkan_tier.das34/2,test_vulkan_dec_tail.das2/3) are this box's known reds at origin/master with the same counts and the same line,workgroup footprint 33028 B exceeds device cap 32768 B - kernel declined(the M5 Vulkan class rails decline; the control was run today with every diff file swapped to master). Thestockedsuite was not run: the dasLLAMA change is parameter mutability on three functions and their hook typedef, compile-gated by the tier test, and the stocked cells that reach the Vulkan KV readback need a Vulkan box.daslib utils modules tutorials examples tests src -j 0): zero LINT031 findings on the tip; the error set is identical to master's.preflight --fullran once: its fast tier stopped onhash-refs(a bare low-numbered PR reference in the commit message, since spelled out). The six lanes it skipped ran once each: docs, tests-cpp and tests-aot green; tests-interp and tests-jit red only ontests/language/failed_generic_unknown_arg_type.dasandfailed_result_type_by_ref_too_big.das, which pin C++ inference fixes newer than the local binary - both green under interp and-jitafter an incremental rebuild; utils-tests red onutils/lint/tests/lint018_mem_size_narrowing.das, whose memcpy destinations LINT031 found - fixed, the fixture green. The fast tier re-ran green on the amended tip.utils/lint/tests/lint031_const_strip_write.das: exactly 23 LINT031 findings, none on the twelve negatives. Every chase arm has a case;tests/lint/test_lint031_memo_and_anchor.daspins the parameter anchor and the per-program callee memo by compiling two programs in one process.utils/internal/doc-verify/main.das -- --page lint: green. Everything the new section names exists:intptrandaddrare builtins,implicitis a parser modifier,memcpy/memset8..128are registered insrc/builtin/module_builtin_runtime.cpp, LINT031 ships in this diff.make-pr --only dupes: the corpus export fails ondaslib/module_group.das(error[20605]: several modules with the same name 'rtti'), a pre-existing module-resolution error unrelated to this diff; no report produced.Claims - stated, not tested
REVIEW_GPU.mdduty for a change under a served path) was not run. The change to those functions is parameter mutability only: with the JIT guard in place the emitted code forvk_rdec_read_kv,vk_rdec_read_kv_bulkandpar_memcpyis the same whether their pointers are declaredvaror marked written by the compiler, and the interpreter ignores the keyword. A break would read as a wrong K/V row after a resident-decode readback.intptrlaundering is invisible to the compiler's write analysis, so the JIT guard does not cover it; LINT031 does. Nothing in the tree uses it.Not done
options _nolintis honored by the lint runner (the CI gate) but not by the MCP lint tool or the LSP diagnostics - the deliberate raw-findings split in.lint_config; the two test files that carry it show LINT031 in an editor.module_group.dascompile failure is a tool defect to fix separately.