Repository navigation
fix: validate indices and sizes instead of relying on asserts - #73
Merged
Merged
Conversation
Every index and size that reaches the dynamic code path was checked only
via assert, which is compiled out in release wheels. From pure Python that
meant out-of-bounds reads, out-of-bounds writes and two hard crashes:
u = hj.DD3Scalar.constant(1.0) # size 3
u.h(-5, -5) # 1.5e-323, arbitrary memory
u.set_h(1000, 1000, 42.0) # OOB write, no error
hj.DDScalar.constant(1.0, 3).set_h(1000, 1000, 42.0) # SIGABRT
d = hj.DDScalar.constant(1.0, 3)
d.set_hm(np.ones((2, 2))) # reads the 2x2 input as 3x3
d.eval([]) # SIGSEGV
hj.DDScalar(f=1, g=[1, 2, 3], hm=np.ones((2, 2))) # silent garbage
hj.DDScalar.variable(i=100, f=1.0, size=3) # OOB write via g(i)
hj.DDScalar.constant(1.0, 3).pad_right(1) # OOB write, truncates
hj.DDScalar.empty(size=-5) # object with size -5
Static types were mostly protected already, because pybind11 rejects the
wrong shape at the caster level (FixedSize(n), [n, n]).
The split follows what each function is:
- g(i), h(i), h(i, j) are element accessors used in loops. They keep
precondition semantics like std::vector::operator[] and stay free of
branches; the Python bindings validate the index and raise IndexError,
because that is where the caller is untrusted.
- Everything that creates, resizes or evaluates a scalar validates its
arguments in the header via the existing throw_exceptions() mechanism,
reusing check_valid_size/check_equal_size and two new helpers. This
also covers the C++ API, and HYPERJET_NO_EXCEPTIONS still degrades to
assert.
empty(size) sits on the hot path of every dynamic operation, so the added
check was measured: 2M iterations of `a * b + a.sin()`, dynamic order 2,
-O3 -DNDEBUG, three alternating runs — 164.0/164.1/166.8 ns/op with the
check against 165.2/167.7/168.2 without. The compare is free next to the
allocation it guards.
This was referenced Jul 26, 2026
oberbichler
added a commit
that referenced
this pull request
Jul 26, 2026
Three memory-safety bugs in a row were found by running the tests under
AddressSanitizer by hand. This makes that part of CI.
Two options on the test target, both off by default:
- HYPERJET_SANITIZE builds with -fsanitize=address,undefined and
-fno-sanitize-recover=all, so undefined behaviour aborts instead of
printing and passing.
- HYPERJET_WERROR promotes warnings to errors. Warnings themselves
(-Wall -Wextra -Wpedantic, /W4) are always on, so they are visible
locally too, but only one CI job fails on them — warning sets differ
between compilers, and an unrelated toolchain should not be able to
block every build.
The new job runs both on clang, which also covers a third front-end next
to GCC 14 and MSVC. Eigen and doctest are now included as SYSTEM headers so
third-party warnings cannot fail our build.
Verified that the job is not decorative: with a reproducer for the
initializer list bug still open in #74, it fails as intended —
test case CRASHED: SIGSEGV - Segmentation violation signal
==1413==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
#7 DDScalar<2l, double, -1l>::DDScalar(std::initializer_list<double>)
hyperjet.h:314
Warnings that -Werror surfaced:
- SScalar::hypot kept f3, a2, b2 and c2 from the DDScalar version, where
they carry the second-order terms. SScalar is first order only, so they
were dead.
- DDScalar(const Data&, index) initialized m_data before m_size while the
declaration order is the reverse (-Wreorder-ctor). Harmless today, since
the vector constructor does not read m_size, but it is a warning inside
a header shipped to users.
Neither of those warnings appeared before, because nothing instantiated the
functions: the tests never touched SScalar::hypot or a dynamic DDScalar. A
warnings job is only as good as the instantiation coverage, so this adds the
missing SScalar::hypot test case — which is what surfaced the dead
variables. The dynamic constructor gets covered by the tests in #73.
Also adds <algorithm> and <stdexcept>, which the header used through
transitive includes only. Included here because the new job compiles with a
front-end the project has not built with before, and missing includes are
exactly what breaks first under a new toolchain.
Workflow-level permissions: contents: read while in this file. The other
three workflows still need the same.
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.
Problem
Every index and size that reaches the dynamic code path was validated only via
assert, which is compiled out in release wheels. From pure Python that meant out-of-bounds reads, out-of-bounds writes and two hard crashes. All of the following were reproduced against the installed release build:u.h(-5, -5)1.5e-323— arbitrary memoryu.h(1000, 1000)u.set_h(1000, 1000, 42.0)(static)Noned.set_h(1000, 1000, 42.0)(dynamic)d.eval([])d.eval([1])at size 2d.set_hm(np.ones((2, 2)))at size 3hj.DDScalar(f=1, g=[1,2,3], hm=np.ones((2,2)))hj.DDScalar.variable(i=100, f=1.0, size=3)g(i)hj.DDScalar.constant(1.0, 3).pad_right(1)hj.DDScalar.empty(size=-5)size == -5Static types were mostly protected already, because pybind11 rejects the wrong shape at the caster level (
FixedSize(n),[n, n]) — which is why only the dynamic variants crash.Fix
The split follows what each function is:
g(i),h(i),h(i, j)are called in loops, so they keep precondition semantics likestd::vector::operator[]and stay free of branches. The bindings validate the index and raiseIndexError, because that is where the caller is untrusted:empty,zero,variable,resize,pad_left,pad_right,eval,hm,set_hmvalidate their arguments in the header via the existingthrow_exceptions()mechanism, reusingcheck_valid_size/check_equal_sizepluscheck_valid_indexandcheck_pad_size. This also covers the C++ API, andHYPERJET_NO_EXCEPTIONSstill degrades toassert.from_arraysis covered transitively throughset_hm.Performance
empty(size)sits on the hot path of every dynamic operation, so the added check was measured rather than assumed — 2M iterations ofa * b + a.sin(), dynamic order 2 size 8,-O3 -DNDEBUG, three alternating runs:Noise — the checked build is faster in two of three runs. The compare is free next to the allocation it guards. The AD kernels (
unary/binary/ternary) and the operators are untouched.Tests
Written first and observed failing.
Python — 12 new parametrized cases.
test_hessian_index_out_of_rangeprobes the boundary (2,-1on a size-2 scalar) for bothhandset_h;test_eval_with_wrong_number_of_values,test_set_hm_with_wrong_shape,test_init_by_array_with_inconsistent_shape,test_variable_with_index_out_of_range,test_negative_sizeandtest_pad_below_current_sizecover the rest. The existing tests already pinned the static size-mismatch paths, so these fill in the dynamic ones. Theevaltest took down the whole pytest process before the fix:C++ —
TEST_CASE("Dynamic size checks")covers the header-level checks that Python cannot reach directly, includinghm(mode, out)with an undersized output matrix. RED was 4 ×did NOT throw at all!.After: C++ 41/41 test cases, 406 assertions in both Debug and Release · Python 166 passed ·
clang-format --Werrorandruff format --checkclean · all 21 entry points now raise a proper Python exception, and valid usage is unchanged.Note
ruff checkreports two pre-existing unused imports intest_SScalar.py(numpy,copy.copy). Not touched here —ruff checkis not part of the CI gate.Remaining items from the same review, unaffected by this PR:
DDScalar<1, double, Dynamic>{1.0, 2.0, 3.0}segfaults —m_datais an empty vector whenstd::copywrites into it.x *= xandx /= xproduce wrong derivatives (self-aliasing) in bothDDScalarandSScalar.resize()silently scrambles the Hessian layout for order 2;pad_right()does it correctly. This PR only stopsresize()from accepting a negative size.