Skip to content

fix: sanitizer + memory fixes - #14

Open
ZocoLini wants to merge 19 commits into
mainfrom
build/sanitizers
Open

fix: sanitizer + memory fixes#14
ZocoLini wants to merge 19 commits into
mainfrom
build/sanitizers

Conversation

@ZocoLini

Copy link
Copy Markdown
Owner

No description provided.

@ZocoLini
ZocoLini force-pushed the build/sanitizers branch 2 times, most recently from 93cfcec to 31dda31 Compare September 9, 2026 09:07
ZocoLini and others added 19 commits September 9, 2026 09:15
CUQDYN_SANITIZE builds our own modules with AddressSanitizer, LeakSanitizer
and UndefinedBehaviorSanitizer, reached through 'scripts/build.sh serial asan'.
The flags go on after deps.cmake so the FetchContent dependencies keep out of
it: hdf5 generates H5Tinit.c by running H5detect, which probes the platform
with deliberately misaligned stores and aborts under UBSan.

scripts/test.sh takes a build directory prefix and passes --output-on-failure,
without which ctest swallows the report the sanitizer writes.

MemorySanitizer is not usable here: clang-only, no Fortran, and it needs every
linked object instrumented, which the prebuilt misqp and xml2 cannot be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
chargelocaloptions_ takes them as double* and writes eight bytes to each,
while problem_specifications declared them REAL(C_FLOAT), four bytes on the
stack. ASan reports the overflow; the values the local solver then read back
were whatever the first half of a double decodes to as a single.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
Five of its cases never released their inputs, which LeakSanitizer reports as
2808 bytes over 31 allocations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
CVodeSVtolerances clones what it is given into cv_Vabstol, and CVodeFree
destroys that clone rather than ours, so the vector leaked on every call.
solve_ode runs once per objective evaluation, which made this the leak that
grows with maxevaluation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
matrix_array_set_index copies into a matrix of its own rather than taking the
one it is handed, so both the array and the four inputs are the test's to free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
The output_function obj_func returns goes back through the deallocator sacess
itself uses, exp_total was malloc'd by the test and destroyexp does not free
the struct it is handed, and data keeps its own initial_values and
observed_idx after create_expetiment_struct has taken the rest.

What load_configuration_XML allocates still leaks; that one is destroyexp's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
load_configuration_XML only reached xmlFreeDoc on the error path, so every
successful load abandoned the whole document. The configuration keeps copies
rather than pointers into it, so the tree is done with by then. It accounted
for 19354 of the 20986 bytes test_ess_obj_function leaked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
Nearly every one of its 141 call sites feeds the result straight into atoi,
atof or strcmp and drops it, so the copy it mallocs leaked. The allocations
are now tracked and released when load_configuration_XML returns; the three
that outlive the load are strdup'd into their structs first, and the four
sites that already freed their own no longer do it twice.

test_ess_obj_function goes from 20986 bytes in 378 allocations to 1263 in 13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
destroyexp released the experiment but not what load_configuration_XML hung
off it: nameMatlab, execution.file, bench.type, bench.X0 and bench.F0, and the
user, global and local option structs with the two strings the last one owns.
vector_char had its free commented out, count_element_multi asked libxml2 for
a string it only counted, and eSSversion was callocated 500 bytes that every
branch immediately overwrote with a literal.

test_ess_obj_function now leaks nothing, down from 23182 bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
problem_specifications allocates F0 with only the initial points that carry an
objective value, so a configuration whose <point> has no fx attribute leaves it
allocated but empty. minloc of an empty array is 0, and X0(:,0) reads the
column before the array, which ASan reports as a use-after-free or a
heap-buffer-overflow depending on what sits there.

Clamping the index keeps the first initial point, which is what the
configuration asked to start from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
Fortran character constants are passed as a pointer and a length and carry no
terminator, so when libgfortran hands a WRITE format string to strndup the
interceptor reads one byte past it and reports a global-buffer-overflow. The
constants are ours, the call is libgfortran's, and the report is neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
The MPI2 variant is gone, and the leak that grew with maxevaluation was
solve_ode releasing nothing for the tolerance vector it handed CVODES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7RAcSVsC6c8r9E4u7W61f
localoptions%tol selects one of three tolerance levels, the way local.tol does
in MEIGO: ssm_localsolver.m switches on 1, 2 and 3, and ssm_defaults.m documents
2 as the default. The Fortran port copied that switch including its lack of a
final branch, so any other value left thres and acc unassigned and the local
solvers read whatever the stack held.

dhc takes thres as its stopping threshold. Read as a large number it returns
after a single evaluation, which is how the local search has been behaving all
along; read as zero it cannot return at all, because the budget check leaves
through vr = thres/10 and ABS(0) .GE. 0 stays true. Under the sanitizers the
stack holds the second kind of garbage: test_ess_solver ran 22 minutes on the
logistic model alone, at 100% CPU, without finishing. The same seed took 2
seconds without them.

Both fall-throughs now take the documented default. The whole asan suite runs in
45 seconds, and test_cuqdyn_algo goes from 42 to 77 seconds in debug because the
local search finally does the work it was meant to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lxq4dki1a1bvYLrx7KrMXe
updateresultsess_ declares its seventh parameter double *totaliter, and the
serial path handed it common_vars%iter, an INTEGER. There is no interface block
for the call, so nothing checked it: the C side loaded eight bytes from a
four-byte-aligned slot to build the iteration count in the result struct. UBSan
reports it as a misaligned load at output.c:2384.

The other four callers all pass results%totaliter, which is the REAL the type
declares for this, so the serial one now fills it and does the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lxq4dki1a1bvYLrx7KrMXe
…figs

TEMPORARY, DROP THIS COMMIT. Borja reviews this part himself; the two fixes
under it stand on their own and do not need it.

Every sacess-*.xml under example-files/ asked for <tol>0</tol>, which is neither
a level nor the sentinel for "not given": the parser takes -1 for that, and
ssm_optset keeps the default when tol is below zero. So 0 read as a value the
user chose and reached the local solvers with no branch to take.

The parser now checks the level the way it already checks solver, and the
sixteen configurations ask for the default level 2. Behaviour does not move:
<tol>2</tol>, <tol>default</tol> and the guard in call_dhc all give 1d-8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lxq4dki1a1bvYLrx7KrMXe
destroyexp left three groups behind. logandtranslation_ allocates logindex
always and transconst, log_max_dom and log_min_dom on some paths;
initrngrandomserial_ allocates the mt19937 generator and the seed array. Those
frees existed, but in destroySystemBiology, which nothing calls, so on every
real path they outlived the struct. destroySystemBiology is left as it is:
destroyexp now leaves the pointers NULL, so its frees are no-ops if it is ever
reached.

create_expetiment_struct already opens with a block that nulls exactly the
fields destroyexp frees, and these six were missing from it. They have to be
there rather than left to the caller's malloc: neither logandtranslation_ nor
initrngrandomserial_ is on every path, and destroyexp cannot tell an allocation
from a stale stack value. Without it destroyexp segfaults on the first free.

destroy_result_data is also declared in the header now; it was defined but
unreachable from outside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lxq4dki1a1bvYLrx7KrMXe
Three leaks at the end of the function. The experiment_total array was malloc'd
and never freed: destroyexp empties the struct but the block holding it belongs
to whoever allocated it, which is how sacess's own main_file.c and
test_ess_obj_function already treat it. init_result_data's bestx_value was never
released either. And N_VSetArrayPointer swapped the returned vector's data for
that buffer, leaving the array New_Serial had just allocated with nothing
pointing at it, while handing the caller a block the solver had made.

The values are copied into the vector instead, which is a handful of doubles,
and then the result data goes back with destroy_result_data. The vector the
caller gets owns its own array, as everything else that comes out of this
library does.

test_cuqdyn_algo drops from 1460508 bytes in 2016 allocations to none, together
with the destroyexp fix under this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lxq4dki1a1bvYLrx7KrMXe
The scenarios leaked the vector execute_ess_solver returns, plus the initial
values and the observability indices read_data_file builds. That is 3160 bytes
in 27 allocations across the three.

Not destroy_cuqdyn_data, which would double free: execute_ess_solver keeps texp,
yexp and initial_values, and destroyexp releases all three. Only the two pieces
the solver never sees are ours, which is the same split test_ess_obj_function
already makes.

The asan suite is now clean, 8 of 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lxq4dki1a1bvYLrx7KrMXe
@ZocoLini
ZocoLini marked this pull request as ready for review September 9, 2026 10:10
@ZocoLini ZocoLini changed the title Build/sanitizers fix: sanitizer + memory fixes Sep 9, 2026
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.

1 participant