Repository navigation
Make dparse() safe to call concurrently: thread-local first path (rxode2#1427) - #32
Conversation
…1427) new_VecZNode() handed every parse the same process-wide path1 and grew it with vec_add, so concurrent dparse() calls (one parser per thread, as rxode2's rxOptExpr() CSE pass does) raced on it and segfaulted.
Compiles a small grammar with long rules plus a pthreads driver that parses the same text from several threads, one parser per thread.
Drops _Thread_local (dparser still allows R >= 3.3 toolchains) while keeping the no-malloc first path. The concurrent test now fails, not skips, when its parser cannot be built; NEWS documents what concurrent callers must avoid.
mattfidler
left a comment
There was a problem hiding this comment.
Codex review (codex exec -s read-only, codex-cli 0.155.1)
Round 1 (at 2aa2a5c) — 4 findings
-
src/parse.c:1323—_Thread_localis an undeclared compiler requirement. dparser allowsR (>= 3.3), and GCC only added_Thread_localin 4.9. Suggested: make the first vector automatic storage owned byreduce_one().
Fixed in 9a3c31f.path1is now a local inreduce_one(), threaded throughbuild_paths()/build_paths_internal()/new_VecZNode()and compared infree_paths(). No TLS, still no malloc. I checked thatmake_PNode()/PNode_equal()only read the path during the call, so nothing outlivesreduce_one(). -
src/dparser.h:22(also :28, :34, :46) — cold wrapper calls race (pre-existing). Concurrent first calls write the same staticfunpointer and callR_GetCCallable()from workers.
Documented in NEWS: callers must resolve the entry points on the main thread first. The test warms up on the main thread. rxode2 is unaffected because it fills adparserPtr.hpointer table at load. -
src/parse.c:1709— default diagnostics call the R API (pre-existing). Syntax errors with recovery enabled reachRprintf(), and unresolved ambiguities reachRf_error().
Documented in NEWS: only error-free parses without R callbacks are safe off the main thread. -
tests/testthat/test-concurrent-dparse.R:34— a build failure became a skip, which silently drops coverage.
Fixed in 9a3c31f: the test nowstop()s with theR CMD SHLIBoutput.
Codex also confirmed it found no other shared scratch state mutated by independent parses in parse.c, scan.c, util.c or dsymtab.c.
Round 2 (at 9a3c31f)
NO ISSUES.
Verification after the fixes
- clang 21 + ThreadSanitizer, concurrent driver: 0 data-race reports (stock 1.3.1-13: 36, all on
path1). - Concurrent test: stock clang 21 build segfaults; this branch passes on gcc 13 and clang 21.
- Full dparser suite: 106 tests, 0 failures, 4 skipped.
- rxode2's CSE and symengine-batch reproducers pass at 2 and 8 threads against this build.
Fixes the dparser side of nlmixr2/rxode2#1427, the root cause of the CRAN r-devel clang segfault in nlmixr2/nlmixr2est#1154.
Problem
new_VecZNode()insrc/parse.chanded the first reduction path to every parse as the single process-widestatic VecZNode path1, and grew it withvec_add(). rxode2 5.1.7 runsdparse()on several statements at once, oneD_Parserper thread (itsrxOptExpr()CSE pass and its symengine translation batch). Those parses share and reallocatepath1, which corrupts it. ThreadSanitizer reportsnew_VecZNode()(parse.c:1332) racingfree_paths()(parse.c:1365) onpath1, and clang builds segfault (address 0x4, "tracked pnodes").Fix
The first path is now a local
VecZNodeinreduce_one(), passed throughbuild_paths()→build_paths_internal()→new_VecZNode()and compared infree_paths(). That keeps the no-malloc fast path, shares nothing between threads, and needs no_Thread_local(dparser still allowsR >= 3.3toolchains). No path pointer outlivesreduce_one():make_PNode()andPNode_equal()only read the path while building children.dparser >= 1.3.2.dparser.hentry points on the main thread first;Regression test / reprex
tests/testthat/test-concurrent-dparse.Rbuilds a small grammar with long rules and a pthreads driver (concurrent_parser.c). It parses a 200-statement input from several threads at once, one parser per thread, after one serial parse on the main thread. The test is skipped on CRAN and on Windows. If the driver fails to build, the test fails instead of skipping.path1The race went unseen because gcc's timing hides it, while TSan shows it regardless of compiler. clang inlines
build_paths()intoreduce_one()and writespath1directly, so every run crashes. On this repo's CI the test catches the bug on the clang (macOS) build and is a passing guard on gcc runners.The dparser test suite passes: 106 tests, 0 failures, 4 skipped.
Reviews
Rf_error();_Thread_localportability was resolved by the stack-owned design;R_GetCCallable()concern does not apply to rxode2, which fills a pointer table at load.codex exec -s read-only), round 1:Companion rxode2 PR: nlmixr2/rxode2#1428 (gates its parallel parsing on this version, safe with either dparser).
🤖 Generated with Claude Code