Repository navigation
Fix two silent memory-safety bugs in recursivecopy!/copyat_or_push! - #667
ChrisRackauckas-Claude wants to merge 2 commits into
Conversation
recursivecopy!(b::AbstractArray{<:StaticArray}, a) looped over
eachindex(a), so a shorter dest silently wrote out of bounds instead of
throwing. copyat_or_push! only checked length(a) >= i, so i=0 (or
negative) fell into the @inbounds update branch and corrupted memory
instead of throwing a BoundsError.
recursivecopy! now uses eachindex(b, a) (throws DimensionMismatch on a
length mismatch, matching Base's own idiom). copyat_or_push! now
rejects i < 1 with a BoundsError before the @inbounds block.
Before the fix, both cases segfault the Julia process on 1.10 and 1.12
(see PR description for the exact crash traces). After the fix they
throw DimensionMismatch / BoundsError and the existing test suite
(Core group, 120/120 Utils Tests) still passes unchanged on both
versions.
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.294
Agent-Model: claude-sonnet-5
Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Round-2 review (Grok 4.7, pr-667/round2): the i >= 1 guard turned a previously correct call into a throw for any AbstractVector whose firstindex isn't 1. copyat_or_push! is exported and its docstring promises to copy into a[i] "when that element exists" -- on an OffsetArray with indices 0:1, index 0 exists and master updates it; the i >= 1 guard threw BoundsError there instead. Use i >= firstindex(a), which is false only when i is below the array's own first valid index, matching master's behavior for every case the original fix needed to cover (the i=0/i=-1 segfault on a plain, 1-based Vector) while leaving a 0-based array's own index 0 untouched. Added a minimal 0-based AbstractVector test type (ZeroVec, avoiding an OffsetArrays test dependency that would conflict with SciML#672's Project.toml changes) and a regression test that copyat_or_push!(z, 0, ...) still updates index 0 while copyat_or_push!(z, -1, ...) still throws. Fails against the i >= 1 code (BoundsError at a correct, in-bounds index 0) and passes after this fix, on both Julia 1.10 and 1.12. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.294 Agent-Model: claude-sonnet-5 Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Independent review (Devin CLI 3000.11.3, model fusion-claude-opus-5-5-high-sidekick-swe-2-medium): CHANGES, risk low. Full reviewVERDICT: CHANGES Reviewed head Blocking findings
Non-blocking findings
What I ran
What I did not verify
🤖 Posted by an AI agent — harness: Devin CLI 3000.11.3 (review), Claude Code 2.1.285 (posting) · model: fusion-claude-opus-5-5-high-sidekick-swe-2-medium |
What changed and why
Two
utils.jlhelpers do their own bounds-adjacent logic and both get it wrong, causing silent memory corruption / crashes instead of a catchable error:recursivecopy!(b::AbstractArray{<:StaticArray}, a::AbstractArray{<:StaticArray})(utils.jl:89) iterateseachindex(a)instead ofeachindex(b, a), so whenbis shorter thanait writesb[i]out of bounds for the trailing indices instead of throwing.copyat_or_push!(a, i, x)(utils.jl:370) only checkedlength(a) >= ibefore entering an@inboundsblock. Foribelowa's own first index, that condition can be trivially true, so the function takes the in-place-update branch and indexes under@inboundswith no check at all.Fix:
recursivecopy!now loops overeachindex(b, a), which throwsDimensionMismatchon a length mismatch (the same idiomrecursivecopy!already uses a few methods down for theAbstractArray/AbstractVectorOfArraycase).copyat_or_push!now rejectsi < firstindex(a)with aBoundsErrorbefore the@inboundsblock, leaving the append-on-overflow behavior (i > length(a)) untouched.Update (round 2, addressing Grok 4.7's review): round 1 shipped the
copyat_or_push!guard asi >= 1, with the PR text claiming the literal1"avoids a behavior change" and thatfirstindex"would introduce one." That claim was backwards.copyat_or_push!is exported and its docstring promises to copy intoa[i]"when that element exists" — for anyAbstractVectorwhosefirstindexisn't1(e.g. anOffsetArraywith indices0:1), index0exists and master correctly updates it; thei >= 1guard threwBoundsErrorthere instead, which is the behavior change the PR claimed to avoid. Fixed by usingi >= firstindex(a), which is false only wheniis below the array's own first valid index — correct for both a plain 1-basedVector(unchanged from round 1) and a 0-based array (now matches master).Before/after
Both bugs are severe enough that "before" isn't a wrong number, it's undefined behavior — a silent out-of-bounds write that may corrupt memory or crash depending on heap layout (observed as a reliable segfault for
recursivecopy!and forcopyat_or_push!(vals, -1, ...);copyat_or_push!(vals, 0, ...)specifically did not reliably reproduce as a crash in round-2's independent testing, though it is still an unchecked out-of-bounds write under@inbounds):After (this branch):
Normal usage (matching lengths, in-bounds
i) is unaffected — verified with the existingrecursivecopy!/copyat_or_push!call sites in the test suite plus the new tests below, including the round-2 0-based-array case:Failing-before / passing-after
Ran
test/Core/utils_test.jldirectly (includes the regression tests) against unmodifiedsrc/utils.jl:Round-2 regression — against the round-1
i >= 1code (commit143d6c5), kept the Vector tests and added only theZeroVeccase:After this fix (commit
c7b71d3):test/Core/utils_test.jlruns to completion with no failures, on both Julia 1.10.12 and 1.12.7 — all 8/8 in thecopyat_or_push!testset, plus everything else in the file.Full
GROUP=Corerun (both Julia versions): exit 0, all testsets green, includingUtils Tests120/120.GROUP=QAon 1.12.7: 20/20 pass.GROUP=QAon 1.10.12 fails on a pre-existingAquamethod-ambiguity report (similar(::AbstractVectorOfArray, ::Type{T}, dims::Tuple{...})vsBase.similar, invector_of_array.jl, untouched by this PR) — confirmed by reproducing the identical failure on unmodified master with the same Julia 1.10.12/Manifest, so it is not caused by this change.What was not verified
utils.jlbounds-check fix; nothing in those groups calls these two functions with bounds-adjacent arguments, andOrdinaryDiffEq's owncopyat_or_push!call sites passsaveiteror the literals1/2, never a non-1-based array).GROUP=QAambiguity failure on Julia 1.10 was reproduced but not investigated/fixed — it predates this PR and is unrelated toutils.jl.recursivecopy!'seachindex(b, a)fix is itself a (correct, intentional) behavior change for a longer destination — master'srecursivecopy!silently copies only the overlapping prefix and leaves the destination's tail untouched for a longerb, matching the nested-array method's existing use of the same idiom, while this PR'seachindex(b, a)now throwsDimensionMismatchfor a longer destination too (previously only a shorter destination triggered the bug this PR targets). The reviewer rated this a non-blocking match with the documented, axis-matching contract (recursivecopyto!is the function for intentionally different shapes) and said no code change is required; flagging it here for visibility since the original PR text undersold it as having "no change to any code path that was previously well-defined."length(a) >= i(the pre-existing upper-bound check, unchanged by either round of this PR) is not equivalent toi <= lastindex(a)for a non-1-based array with a gap (e.g. indices5:7,i=2), which is a pre-existing, separate issue outside the scope of this fix.Anything a reviewer should push back on
firstindex(a)relies onaxes/Base's genericfirstindexfallback for whateverAbstractVectoris passed; this is correct for every case tested (Vector, the newZeroVec, and — per the round-2 reviewer's manual check —OffsetArray), but I have not exhaustively audited everyAbstractVectorsubtype in the ecosystem for a non-standardfirstindex.Please ignore until reviewed by @ChrisRackauckas.
Risk assessment
src/utils.jlonly, two internal/exported helper functions (recursivecopy!'sStaticArray-eltype method,copyat_or_push!). Both functions already throw for the cases this PR targets would otherwise crash; the round-2 fix specifically restores the exact same in-bounds behavior master has for non-1-based arrays, which round 1 had incorrectly broken. No public API signature change.DimensionMismatch/BoundsError) shown above; round-1-vs-round-2 regression test shown failing/passing;GROUP=Corefull pass on Julia 1.10 and 1.12;GROUP=QApass on 1.12, pre-existing unrelated failure reproduced on 1.10 master.OffsetArrayregression, plus non-blocking notes on therecursivecopy!longer-destination behavior and the pre-existinglength(a) >= ivslastindexgap). The blocking finding is addressed in round 2 (commitc7b71d3); round-2 independent review still pending.🤖 Generated with Claude Code
https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv