Skip to content

JIT withholds readonly on a const parameter the body writes through; LINT031 rejects the const-strip write (#3991) - #3996

Merged
borisbat merged 1 commit into
masterfrom
bbatkin/jit-const-readonly-3991
Sep 11, 2026
Merged

JIT withholds readonly on a const parameter the body writes through; LINT031 rejects the const-strip write (#3991)#3996
borisbat merged 1 commit into
masterfrom
bbatkin/jit-const-readonly-3991

Conversation

@borisbat

Copy link
Copy Markdown
Collaborator

Behavior change: daslib/lint now rejects a const pointer or reference parameter the body writes through (LINT031, on by default) - a project that lints such code must declare the parameter var.

Why. The JIT gave every const pointer or reference parameter the LLVM readonly attribute from its declared type alone. A body that stripped the const and wrote anyway (a reinterpret, an intptr round trip, a memcpy destination) was miscompiled from --jit-opt-level=1 up: the caller read the pre-call value. The interpreter and AOT were correct. Reported as #3991.

What changes.

  • The JIT withholds readonly when the compiler's per-argument write mark is set, the mark the DCE side already trusts; codegen version bumped, emitter pin re-hashed.
  • LINT031 reports a const pointer or reference parameter the body writes through, anchored at the parameter; it walks each write target to its root and judges a call slot by the callee's body.
  • The sites the rule found take var: two JIT visitor overrides, the coverage body hook, the memset test wrappers, a lint fixture's memcpy destinations, dasLLAMA's par_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 reads 0 -> caller reads the stored value.
  • Linting such a function: silent -> error[50503]: LINT031 at the parameter.
  • RdecReadKvFn / RdecReadKvBulkFn: an out-of-tree implementer with const kp/vp no longer matches the typedef.
  • Every cached JIT DLL misses once (version 0x7e).

Where to look. apply_impl_param_attrs in modules/dasLLVM/daslib/llvm_jit.das (one condition); lint031_root and lint031_callee_writes in daslib/lint.das (the chase and the loaded-address stop are the risky parts).

Validation, claims, ledger

Validation

  • Negative control: with the guard replaced by true and the cache key bumped, tests/jit_tests/const_arg_readonly.das fails 5 of 7 arms under -jit at 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.das under -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.das 34/2, test_vulkan_dec_tail.das 2/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). The stocked suite 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.
  • Tree-wide lint sweep, the nightly's command (daslib utils modules tutorials examples tests src -j 0): zero LINT031 findings on the tip; the error set is identical to master's.
  • preflight --full ran once: its fast tier stopped on hash-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 on tests/language/failed_generic_unknown_arg_type.das and failed_result_type_by_ref_too_big.das, which pin C++ inference fixes newer than the local binary - both green under interp and -jit after an incremental rebuild; utils-tests red on utils/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.
  • Fixture 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.das pins 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: intptr and addr are builtins, implicit is a parser modifier, memcpy/memset8..128 are registered in src/builtin/module_builtin_runtime.cpp, LINT031 ships in this diff.
  • Codex review round: one P1, rejected - it read the LINT030 entry's "ships default-off" as LINT031's; LINT031 is on by design.
  • make-pr --only dupes: the corpus export fails on daslib/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

  • GPU-vs-CPU parity on the Vulkan resident decode path (the REVIEW_GPU.md duty 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 for vk_rdec_read_kv, vk_rdec_read_kv_bulk and par_memcpy is the same whether their pointers are declared var or 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.
  • intptr laundering 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 _nolint is 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.
  • The dupes gate's module_group.das compile failure is a tool defect to fix separately.

…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>

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.

🔵 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 readonly when the compiler marked an argument as written (access_ref), and bump JIT cache version/hash to invalidate cached DLLs.
  • Add LINT031 to daslib/lint to detect const-stripped writes (including via intptr laundering and write-through call slots), plus documentation updates for the new rule.
  • Update identified call sites/tests/fixtures to use var parameters 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.

@borisbat
borisbat merged commit b5b2a7c into master Sep 11, 2026
32 checks passed
@borisbat
borisbat deleted the bbatkin/jit-const-readonly-3991 branch September 11, 2026 00:32
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.

2 participants