Repository navigation
fix(expr): give every evaluator its own ExprTk parser (#257) - #279
Merged
Merged
Conversation
`compile()`. The regression test it added then failed ~10% of the time on main
(2 of 20), with a fourth signature it was never calibrated against: SIGTRAP —
the macOS malloc freelist check — raised in `NetworkModel::clone()` while
another thread sat in `ExprTkEvaluator::evaluate()` under CVode, GIL released.
That thread was not compiling, so no lock on `compile()` was going to cover it.
The mechanism is one line of ExprTk. `parser::compile()` ends with
symtab_store_.symtab_list_ = expr.get_symbol_table_list();
and never clears it, so a parser retains a strong handle on the symbol table of
the last expression compiled through it — and ExprTk refcounts symbol tables
with a plain `std::size_t`. One parser behind two evaluators means thread B's
compile dropping thread A's symbol table while thread A churns that same counter
with no lock at all: `register_symbol_table`, growth of its expression vector,
its own destructor. A lost update runs `clear()` on a symbol table whose
variable addresses are already baked into A's live compiled nodes, and the crash
lands wherever the corrupted heap is next touched.
No lock could close that, because the counter is touched on paths no evaluator
API sees. So the sharing goes instead: an ExprTkEvaluator now owns all of its
ExprTk state and shares none of it, `clone_empty()` inherits nothing, and the
The performance argument for sharing was illusory. It avoided re-constructing
the ~100KB parser per clone, but `NetworkModel copy;` default-constructs an
evaluator that clone() replaces one line later, so that construction was being
paid and discarded every clone anyway. The parser is now built lazily on first
compile, which makes the discarded one free. Measured clone(), before -> after:
BIOMD0000000701 521 -> 527 us, func_composition.net 92 -> 94 us,
simple_decay.net (no expressions) 67 -> 34 us.
wshlavacek
force-pushed
the
fix/257-evaluator-owns-its-exprtk-parser
branch
from
August 10, 2026 19:39
c64797c to
28cf0d4
Compare
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.
Closes #257.
What the residual race actually was
#201 found two threads compiling through one
exprtk::parserand serializedcompile(). Its regression test then went on failing about 10% of the time onmain(measured: 18 of 20), with a signature #201 was never calibrated against —SIGTRAP, the macOS malloc freelist check, raised inNetworkModel::clone()while another thread sat inExprTkEvaluator::evaluate()underCVodewith the GIL released. That thread was not compiling, so no lock oncompile()was ever going to cover it.The mechanism is one line of ExprTk.
parser::compile()ends withand never clears it, so a parser retains a strong handle on the symbol table of the last expression compiled through it — and ExprTk refcounts symbol tables with a plain
std::size_t, not an atomic. One parser behind two evaluators therefore means thread B's compile dropping thread A's symbol table while thread A is churning that same counter with no lock at all: inregister_symbol_table, in the growth of its expression vector, in its own destructor. A lost update runsclear()on a symbol table whose variable addresses are already baked into A's live compiled nodes. The result is a corrupt heap, so the crash lands wherever the next allocation looks — which is exactly the clone-sideoperator newin the issue's crash report.Reproduced directly, not inferred. A standalone harness mimicking the old design (shared parser, mutex around
compile(), 8 threads cloning/compiling/evaluating) dies withTrace/BPT trap: 5in 19 of 20 runs, plusSIGSEGVandSIGBUS. Two variants isolate the cause: giving each evaluator its own parser is clean 20 of 20, and keeping the shared parser but scrubbingsymtab_store_after each compile is also clean 20 of 20. The retained symbol-table handle is the whole of it.The fix
No lock could close this, because the counter is touched on paths no evaluator API sees. So the sharing goes instead, which is what the issue asked for — "deciding which of the evaluator's parts can legitimately be shared at all". The answer is none:
ExprTkEvaluatorowns its parser, symbol table, expression list and function adapters, and shares none of them.clone_empty()inherits nothing.shared_ptr<void>back door in the header.The audit the issue asked for came back clean otherwise: every other
Implmember is per-evaluator, and the three file-scope reserved-name sets are function-local statics that are read-only after initialization.New
ExprTkEvaluator::parser_identity()makes the invariant assertable rather than merely documented.Cost: none, and negative where the clone compiles nothing
The performance argument for sharing was illusory. It existed to avoid re-constructing the ~100 KB
exprtk::parser<double>(51 µs, measured) per model clone — butNetworkModel copy;default-constructs an evaluator thatclone()replaces one line later, so that construction was already being paid and thrown away on every clone. Lazy construction makes the discarded evaluator genuinely free.NetworkModel::clone(), before → after:BIOMD0000000701.xml(71 params, events)func_composition.netsimple_decay.net(no expressions)Tests
Two new cases in
tests/test_bngsim.cpp, both run against the pre-fix design before being trusted:test_evaluator_clone_shares_no_exprtk_state— the invariant, deterministically: a clone's parser is not its source's, and a fresh evaluator has built none yet.test_concurrent_clone_compile_evaluate— the failure it prevents: 8 threads each clone, rebuild from the cached preprocessed strings, integrate, and fire a lazy compile mid-integration, with every evaluation checked against the serial answer.Rebuilt on the old shared-parser evaluator, the test binary died in 8 of 8 runs, always inside that second case, always on the
SIGTRAP. On the fix it passes, andctestis 8/8 green.tests/CMakeLists.txtlinksThreads::Threadsfor it — needed on glibc, harmless on Apple.Verification
python/tests/test_expression_parser_thread_safety.py: 25 of 25 clean (18 of 20 onmain). Its docstring and failure message now distinguish the two defects a signal can mean.ctest: 8/8.clang-format(v17.0.6) andruff(v0.14.0) clean on every touched file.test_parallel_workers_clone_the_model.py's docstring said clones "genuinely share" the parser; corrected, since they no longer share anything.