Repository navigation
fix(analysis): order thread entries only by a proof over every instance - #231
Merged
Merged
Conversation
…another Two thread entries ordered once, the first joined before the second starts, are taken as ordered for every instance. A later instance of one, started again beside the other, is then never reported against it: whether the other entry starts it itself, a helper starts it, a worker ordering them is started twice, each level of a recursion orders them, its entry comes from a select or an array, a recursive thread entry starts it, or a function ordering them is also called through a pointer, by a worker, with or without a direct caller. So is a thread still running when a helper catches the exception that skipped its join. Each fixture expects its race on shared, and a test checks that it is reported between first and second, not only on the symbol. Nine fail on main; the other three already report the race and guard the change.
…concurrent first is joined before second starts and neither starts again, or helpers start and join first, second, then first again: no two of these threads ever run at once. Expected: no diagnostic, as on main.
The task analysis ordered two entries whenever, in one function, the spawn of one followed the spawn of the other at a point where that one no longer ran. Nothing in that rule looked at the entries' other instances, so an order seen once hid every later overlap. The rule is removed: the order between two entries now comes only from the contained-entry proof, which weighs every occurrence of both. That proof now carries the whole guarantee, so the instances it did not weigh are refused: - a function an indirect call may enter, root or not, may run in any thread, and the instances it starts there are not among the occurrences: it is treated as reached from a worker, and so is every function it calls; - an occurrence counts as reached from a worker when any of its frames is, not only the outermost one, so a function also called directly orders nothing; - a thread creation missing its entry argument is refused, as one whose entry is unknown already was.
… to another Where the target has no constructor aliases, as on Darwin, the complete-object constructor of std::thread is a function of its own that calls the base-object one. That call is classified as a thread start, and since every call to the constructor is itself a start, nothing calls the constructor directly: it became a root of the contained-entry proof, with a start whose entry is an unbound parameter. The proof then refused every pair of the unit, and with the in-function rule gone, the two phases of cpp_vector_thread_moved_in_phases_no_fp.cpp were reported racing on macOS. A std::thread constructor handing its thread to another starts no thread of its own: every call to it is a start already counted where it is made.
SizzleUnrlsd
marked this pull request as ready for review
October 26, 2026 03:13
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.
Summary
Two thread entries ordered once, the first joined before the second starts, were taken as ordered for every instance: a later instance of one, started beside the other, was never reported against it. The order between two entries now comes only from the contained-entry proof, which weighs every occurrence of both, and that proof refuses the instances it cannot weigh.
Fixes #224.
Root cause
TaskConcurrencyAnalyzer::analyzeadded a pair tosequencedEntryPairswhenever, in one function, the spawn of one entry followed the spawn of the other at a point where that one no longer ran. The rule looked at two spawn sites, never at the entries' other instances.mayRunConcurrently, read by the data race and publication ordering checkers, trusts every pair in that set, so one order hid every later overlap.Change
sequencedEntryPairsis exactly whatcollectContainedEntryPairsproves: for every instance of one entry and every instance of the other, one is joined before the other starts.hasAddressTaken()), root or not, may run in any thread, and the instances it starts there are not among the occurrences: it is treated as reached from a worker, and so is every function it calls;std::threadconstructor handing its thread to another starts no thread of its own. Where the target has no constructor aliases, as on Darwin, the complete-object constructor calls the base-object one. That call was taken for a start, and the constructor became a root whose start had an unbound parameter for entry, so the proof refused every pair of the unit. On macOS, with the in-function rule gone, this madecpp_vector_thread_moved_in_phases_no_fp.cppreport its two phases racing.Tests
14 fixtures, plus
testEntriesOrderedElsewhereStillRaceFirstAgainstSecond, which checks that each of the 12 race fixtures reports its race betweenfirstandsecond, not only a race on the symbol. No existing expectation changes.data_race_entry_started_again_beside_other_race.cdata_race_entry_started_by_other_entry_race.cdata_race_entries_ordered_by_helpers_then_overlapping_race.cdata_race_entry_started_again_by_helper_beside_other_race.cdata_race_entries_ordered_by_worker_started_twice_race.cdata_race_entries_ordered_at_each_level_of_recursion_race.cfirst ↔ secondmisseddata_race_entry_started_again_through_select_race.cdata_race_entry_started_again_through_entry_array_race.cdata_race_entry_started_by_recursive_thread_entry_race.cdata_race_entries_ordered_by_root_called_indirectly_race.cdata_race_entries_ordered_by_helper_also_called_indirectly_race.ccpp_pthread_thread_left_by_exception_caught_in_helper_race.cppdata_race_entries_ordered_once_no_fp.cdata_race_entries_ordered_through_helpers_no_fp.cThe fourth commit adds no fixture. Its regression test is the existing
cpp_vector_thread_moved_in_phases_no_fp.cppon the macOS job: ELF targets alias the two constructors, so no Linux build has the constructor body it concerns.Verification
Run on
c23f9e2d835c96af48f85a7628ea295e6eb1da1a, whose tree was checked file by file against the tree the runs below used: a native build on Ubuntu 24.04, clang 20.1.2.RED
7bc8879:CMake CTest (macos-14)failed oncpp_vector_thread_moved_in_phases_no_fp.cpp(expected 0, got 1). This was reproduced on Linux by compiling forarm64-apple-macosx14.0against the macOS SDK headers and Homebrew's libc++ 20, in the same search order as on the Mac: main 0,7bc88791,c23f9e20.GREEN
coretrace_concurrency_analysis_tests: 0 failures;ctest: 7/7.ctest7/7.tests/integration/cli/test_human_output_golden.py: 10 passed.Corpus, main against the fix
Every fixture and its full diagnostics, in four configurations:
ubuntu:24.04with the host's exact LLVM 20 packages (1:20.1.2-0ubuntu1~24.04.3); the driver selects GCC 14 there (_GLIBCXX_RELEASE14);-nostdinc++ -isystem /usr/lib/llvm-20/include/c++/v1(_LIBCPP_VERSION200100);All four give 912 fixtures: 903 identical, 9 different (exactly the 9 RED fixtures), 0 errors.
Static checks
clang-format 18 and 20,
scripts/check-license-compliance.sh,git diff --check,scripts/ci/commit_checker.py.Probes kept out of the suite, main → fix
std::jthreadrestart, astd::threadrestart, a starter reached only from dead code, and a thread entry also called directly each go from 0 to 1 (first ↔ second).main, a helper returning before its join, and the publication probes of A weak publication is missed when its producer is started again beside the consumer #229 are unchanged.Soundness notes
Contract.
mayRunConcurrentlyneeds: no instance of one entry runs beside an instance of the other. The removed rule guaranteed that of two spawn sites only.collectContainedEntryPairsguarantees it for every pair of occurrences it enumerates, ordered at the frame where their call chains diverge. It refuses when the enumeration is incomplete (unknown or missing entry, recursion, bound overflow), or when an occurrence goes through a function entered from elsewhere.ThreadCompletion::endedgives the completion it relies on, on every path to a normal return; at call frames it is checked again, which matters when a caller catches an exception.Assumptions.
thread_completion_analysis, checked again at call framesstd::threadconstruction stands for every thread its constructor startsExemptions.
entryConcurrency.relevantfixpoint.std::threadconstruction inside astd::threadconstructor is the thread the call to that constructor already counts.Channels.
Mutants, each run against the full suite; the macOS fixture was also measured under each one:
pthread_createwith fewer arguments than its declaration reaches it, which is undefined behaviourstd::threadconstructorstd::threadconstructor callspthread_createdirectly (libc++ goes through__libcpp_thread_create, libstdc++ through_M_start_threadin the shared library)Not covered.
first ↔ secondbut not each entry against itself.std::asyncandthrd_create.Related
#228, #229 and #230 were found while measuring this change; none is changed by it.