Task: Fix rate_engine concurrent update/rates crash (SIGSEGV/SIGABRT)
Table of Contents
This page documents a task in the Cross-rates matrix (CRM) story. It captures the goal, current status, acceptance, and any notes or results.
Goal
rate_engine::update() and rate_engine::rates()/rate() run correctly under the documented single-producer/multi-reader concurrency contract with no crash, verified by a stress run of the existing 'concurrent updates and batched reads never crash or hang' test (many iterations, under ctest and standalone) plus ideally a TSAN build.
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | Cross-rates matrix (CRM) |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-07-12 |
Acceptance
- Root cause identified; fix applied; the concurrency test passes reliably across repeated runs (both standalone with the xml reporter and under ctest -R with parallel scheduling); existing single-threaded rate_engine/topology_builder/risk_recentering tests still pass.
Plan
- Reproduce: confirmed 5/5 with the full test binary + xml reporter
(
ores.analytics.quant.tests -r xml::out=...), matching the original report exactly. - Build a standalone ThreadSanitizer binary (TSAN is not wired into any
CMake preset – compiled
rate_engine.cpp=/=topology_builder.cpp=/ =risk_recentering.cpp+ the test.cppfiles directly withclang++ -fsanitize=thread, linked against the vcpkg staticlibCatch2.a): first just the concurrency test in isolation (no race reported, 15/15 clean – consistent with the original investigation's own failure to reproduce in isolation), then the full three-test-file suite with the xml reporter (still no race reported, 3/3 clean) – TSAN saw no data race inrate_engine=/ =crm_topology=/=immeritself under the exact conditions that reliably crash the real binary. - That result redirected the investigation: if TSAN instruments
everything in the race and still finds nothing, the race is most
likely in code TSAN's instrumentation doesn't cover – e.g. the
vcpkg-prebuilt
libCatch2.a, which was not compiled with-fsanitize=thread. That pointed at Catch2's own assertion machinery, which is documented upstream as not thread-safe: callingREQUIRE=/=CHECKfrom any thread other than the one running theTEST_CASEmutates shared, non-atomic per-test result-capture state. - Found exactly that: the concurrency test's 4 reader threads called
REQUIRE(...)directly inside their thread lambdas (rate_engine_tests.cpp, therates()result loop). This alone explains every reported symptom – reproduces only with the xml reporter (more shared reporter state touched per assertion, more surface for the race), does not reproduce running the test in isolation with the default reporter (less contention around Catch2's internals), and the SIGABRT was literally Catch2's own re-entrancy assertion (OutputRedirect::activate) firing when two threads hit its capture machinery at once. - Fix: reader threads now only count failures into a plain
std::atomic<int>; the main thread asserts once, after every thread has joined – the standard safe pattern for multi-threaded Catch2 tests. No production code (rate_engine.cpp,crm_topology.hpp,rate_snapshot.*) changed at all. - Verified: 20/20 clean runs of the full binary with the xml reporter
(the exact condition that reproduced 5/5 before the fix), 10/10 clean
ctest -Rruns.
Notes
How it was found
Discovered while raising a PR for an unrelated story (Extract stochastic
process math from ores.synthetic into ores.analytics.quant), running the
mandatory pre-PR full build + ctest on branch
feature/move-istochasticprocess-interface (environment: jolly_knuth,
preset linux-clang-debug-make). The new commits on that branch touch
only ores.analytics.quant's process/domain-validation code (new files
under service/processes/, service/process_factory.*,
domain/process_parameter_validation.*) and never touch rate_engine.*,
crm_topology.hpp, rate_snapshot.*, or rate_engine_tests.cpp — this
is pre-existing behaviour in code from the earlier CRM story (PRs #1510,
#1511, #1512), not something introduced by that branch.
Reproduction
- Full suite via
ctest --preset linux-clang-debug-make: intermittently failsores.analytics.quant.testswith either SEGFAULT or SIGABRT (the SIGABRT case is a secondary failure – Catch2'sCatch::OutputRedirect::activate()hits!m_redirectActive && "redirect is already active"because the crash happens mid-capture; the real fault is still the concurrency test). - Reliably reproduces standalone when the whole binary runs with the
xml::outreporter that CI/ctest uses, e.g.:ores.analytics.quant.tests -r xml::out=.... Running the failing test case alone in isolation (ores.analytics.quant.tests "concurrent updates and batched reads never crash or hang") and running the full binary with the default compact reporter did not reproduce across several attempts – the crash is timing/scheduling-sensitive, consistent with a genuine data race rather than a deterministic logic bug. - Test in question: rate_engine_tests.cpp,
TEST_CASE("concurrent updates and batched reads never crash or hang", "[rate_engine][thread]")(line ~219). One writer thread callsengine.update(...)in a loop; four reader threads callengine.rates(...)2000 times each – this matchesrate_engine's own documented contract in rate_engine.hpp (single producer, many concurrent readers), so the test is not misusing the API as far as could be determined.
Coredumps
Two coredumps captured via coredumpctl on this machine (environment
jolly_knuth), both for ores.analytics.quant.tests:
| PID | Signal | Timestamp (BST) | Coredump path (zstd, use coredumpctl dump <PID> -o <file> to extract) |
|---|---|---|---|
| 535290 | SIGSEGV | 2026-07-12 12:27:43 | /var/lib/systemd/coredump/core.ores\x2eanalytics\x2e.1000.40e75b2c08a142ea85e12da39c72ab8f.535290.1783855662000000.zst |
| 543053 | SIGABRT | 2026-07-12 12:33:29 | /var/lib/systemd/coredump/core.ores\x2eanalytics\x2e.1000.40e75b2c08a142ea85e12da39c72ab8f.543053.1783856009000000.zst |
Retrieve with e.g. coredumpctl info 535290 or
coredumpctl gdb 535290 (systemd-coredump retention is time/space-bounded
on this machine, so these may age out – reproduce fresh if they are
gone; see Reproduction above).
PID 535290 (SIGSEGV) – three relevant threads
Writer thread (the single producer calling
update()) crashed inside immer's refcounted node destructor, reached viarate_snapshot::transient()called fromrate_engine::update:immer::detail::rbts::node<...vertex_state...>::dec() immer::detail::rbts::dec_visitor::visit_regular<...> immer::detail::rbts::empty_regular_pos<...>::visit<dec_visitor> immer::detail::rbts::rbtree<...>::traverse<dec_visitor> immer::detail::rbts::rbtree<...>::dec() immer::detail::rbts::rbtree<...>::~rbtree() immer::vector<vertex_state,...>::transient() [inside rate_snapshot::transient()] ores::analytics::quant::domain::rate_snapshot::transient() ores::analytics::quant::service::rate_engine::update(driver_quote const&)
A reader thread crashed inside
std::string::operator==called fromstd::unordered_map::find, reached viacrm_topology::currency_id_for:std::basic_string::max_size / size [operating on a corrupt/garbage string] std::operator==<char,...> std::equal_to<basic_string<char>>::operator() std::_Hashtable<...>::_M_key_equals std::_Hashtable<...>::_M_locate std::_Hashtable<...>::find std::unordered_map<string, currency_id>::find ores::analytics::quant::domain::crm_topology::currency_id_for ores::analytics::quant::service::rate_engine::rate_from_snapshot ores::analytics::quant::service::rate_engine::rates
The garbage/corrupt
std::stringbeing compared inside the hashtable lookup (crashing inmax_size()=/=size()before even reaching the byte comparison) is the strongest signal here:crm_topologyis constructed once and never mutated (see its class comment in crm_topology.hpp), so a concurrent read-only lookup into itscurrency_index_should never see a torn/corrupt key – unless something elsewhere has corrupted the heap (e.g. a use-after-free or buffer overrun in the immer-backedrate_snapshot=/=immer::atompath) and this is simply where the corruption was first observed.- A third thread was mid-
__clone3(thread spin-up), not directly relevant.
PID 543053 (SIGABRT) – secondary failure, same root cause
Same test, same call sites implicated (crm_topology::currency_id_for
via rate_engine::update from the writer thread, and via
rate_from_snapshot from a reader). The abort itself is Catch2's
OutputRedirect::activate() re-entrancy assertion, which fires because
the underlying SIGSEGV/heap corruption happens while Catch2's XML
reporter has an output capture active – i.e. this is very likely the
same underlying bug, just observed via the abort path instead of a
raw segfault.
Working hypothesis (unconfirmed – needs an implementer's investigation)
rate_engine::update()is documented as the single producer, andrate()=/=rates()as safe for concurrent readers, viaimmer::atom<rate_snapshot> snapshot_(see the class doc in rate_engine.hpp). The crash inside the writer's own call torate_snapshot::transient()– i.e. destructing/reference-counting theimmer::vector<vertex_state>it just loaded viasnapshot_.load().get()in rate_engine.cpp line ~108 – suggests the refcount on the immer tree nodes is being corrupted by concurrentload()calls from the four reader threads racing with the single writer'sload()=/=store(), despite immer'srefcount_policy=/=spinlock_policymemory policy nominally being built for exactly this. Whether that is a genuine bug in howimmer::atomis used here (e.g. a missing memory fence, an unsafe.get()on the loaded box, or an assumption violated byrate_snapshot's own copy/move semantics) or an upstream immer issue needs a focused investigation – ideally build and runores.analytics.quant.testsunder ThreadSanitizer (TSAN is not currently wired into any CMake preset for this component) to get a precise data-race report instead of reasoning from crash-site backtraces alone.- Check
domain/rate_snapshot.hpp=/.cpp and =domain/vertex_state.hppfor how they wrap/copy the underlyingimmer::vector; check whetherimmer::atom::load()as used here (.load().get()) returns a properly refcounted owning copy or something that can be invalidated by a concurrentstore().
Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario, run
through the QA Validation Runner panel) that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
PRs
| PR | Title |
|---|---|
| #1524 | [analytics.quant] Fix intermittent SIGSEGV/SIGABRT in rate_engine concurrency test |
Review
| Comment summary | File | Decision | Notes |
|---|---|---|---|
Result
Root cause was a test bug, not a production bug: the concurrency test's
4 reader threads called Catch2's REQUIRE() macro directly from worker
threads. Catch2's assertion machinery is not thread-safe (mutates
shared, non-atomic per-test-case result-capture state), so concurrent
REQUIRE=/=CHECK calls from multiple threads is itself a data race –
independent of anything rate_engine=/=crm_topology=/=immer::atom does.
That explains every symptom in the original report: reproduces only
with the full binary + xml reporter (more shared Catch2 reporter state
touched per assertion), not in isolation (less threading contention
around Catch2's internals), and the SIGABRT was Catch2's own
OutputRedirect::activate() re-entrancy assertion firing.
Confirmed via a standalone ThreadSanitizer build (compiled directly with
clang++ -fsanitize=thread, since TSAN isn't wired into any CMake
preset) that found zero data races in the library code itself, under
the exact conditions (full suite, xml reporter) that reliably crash the
real binary – which is what redirected the investigation away from
rate_engine=/=immer and towards the vcpkg-prebuilt libCatch2.a
(not TSAN-instrumented) and its documented threading limitation.
Fix: rate_engine_tests.cpp's reader threads now collect failures into
a plain std::atomic<int>; only the main thread asserts, after every
thread has joined. rate_engine.cpp, crm_topology.hpp,
rate_snapshot.*, and every other production file are unchanged.
Verified: 20/20 clean runs of the full test binary with the xml
reporter (the exact condition that crashed 5/5 before the fix), 10/10
clean ctest -R runs, single-threaded topology_builder=/=rate_engine=/
=risk_recentering tests all still green (23 test cases total).