Skip to content

fix(analysis): order thread entries only by a proof over every instance - #231

Merged
SizzleUnrlsd merged 4 commits into
mainfrom
fix/entry-pairs-every-instance
Oct 26, 2026
Merged

SizzleUnrlsd merged 4 commits into
mainfrom
fix/entry-pairs-every-instance

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

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::analyze added a pair to sequencedEntryPairs whenever, 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

  1. The in-function rule is removed. sequencedEntryPairs is exactly what collectContainedEntryPairs proves: for every instance of one entry and every instance of the other, one is joined before the other starts.
  2. Since that proof now carries the whole guarantee, the instances it did not weigh are refused:
    • a function an indirect call may enter (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;
    • an occurrence counts as reached from a worker when any of its frames is, not only the outermost one. This covers a function that has a direct caller and is also called through a pointer by a worker;
    • a thread creation missing its entry argument is refused, as one whose entry is unknown already was.
  3. A std::thread constructor 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 made cpp_vector_thread_moved_in_phases_no_fp.cpp report its two phases racing.

Tests

14 fixtures, plus testEntriesOrderedElsewhereStillRaceFirstAgainstSecond, which checks that each of the 12 race fixtures reports its race between first and second, not only a race on the symbol. No existing expectation changes.

Fixture Pins main
data_race_entry_started_again_beside_other_race.c the reproducer of #224 missed
data_race_entry_started_by_other_entry_race.c the other entry starts it missed
data_race_entries_ordered_by_helpers_then_overlapping_race.c helpers order them, then both run at once reported
data_race_entry_started_again_by_helper_beside_other_race.c a helper starts it again reported
data_race_entries_ordered_by_worker_started_twice_race.c a worker orders them and is started twice missed
data_race_entries_ordered_at_each_level_of_recursion_race.c each level of a recursion orders them first ↔ second missed
data_race_entry_started_again_through_select_race.c its entry comes from a select missed
data_race_entry_started_again_through_entry_array_race.c its entry comes from an array missed
data_race_entry_started_by_recursive_thread_entry_race.c a recursive thread entry, which no caller expands, starts it missed
data_race_entries_ordered_by_root_called_indirectly_race.c two workers call the function ordering them through a pointer missed
data_race_entries_ordered_by_helper_also_called_indirectly_race.c that function also has a direct caller, so it is no root missed
cpp_pthread_thread_left_by_exception_caught_in_helper_race.cpp a helper catches the exception that skipped the join reported
data_race_entries_ordered_once_no_fp.c control: ordered once, never started again no diagnostic
data_race_entries_ordered_through_helpers_no_fp.c control: helpers run first, second, first in turn no diagnostic

The fourth commit adds no fixture. Its regression test is the existing cpp_vector_thread_moved_in_phases_no_fp.cpp on 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

  • The final tests on main's sources: 26 failures, all from the 9 fixtures marked "missed" above (the pair test and their rows). The other five pass on main.
  • For the fourth commit, on 7bc8879: CMake CTest (macos-14) failed on cpp_vector_thread_moved_in_phases_no_fp.cpp (expected 0, got 1). This was reproduced on Linux by compiling for arm64-apple-macosx14.0 against the macOS SDK headers and Homebrew's libc++ 20, in the same search order as on the Mac: main 0, 7bc8879 1, c23f9e2 0.

GREEN

  • coretrace_concurrency_analysis_tests: 0 failures; ctest: 7/7.
  • GCC 13.3 with no build type (the CI's configuration): build and ctest 7/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:

  • libstdc++ 13, native;
  • libstdc++ 14, in a container built from ubuntu:24.04 with the host's exact LLVM 20 packages (1:20.1.2-0ubuntu1~24.04.3); the driver selects GCC 14 there (_GLIBCXX_RELEASE 14);
  • libc++ 20.1, in the same kind of container, through -nostdinc++ -isystem /usr/lib/llvm-20/include/c++/v1 (_LIBCPP_VERSION 200100);
  • the macOS configuration above, for every C and C++ fixture.

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

Soundness notes

Contract. mayRunConcurrently needs: no instance of one entry runs beside an instance of the other. The removed rule guaranteed that of two spawn sites only. collectContainedEntryPairs guarantees 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::ended gives 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.

Assumption Status
every instance comes from a spawn reached from a root through direct calls enforced by refusals (unresolved entry, recursion, unvisited function reached from a worker, function entered through a pointer); not in another unit (#230)
a root runs on the initial thread, one call at a time enforced for functions reached from a worker, directly or through a pointer
a completion covers every instance of its spawn contract of thread_completion_analysis, checked again at call frames
a call classified as a std::thread construction stands for every thread its constructor starts the classifier's contract, which every other analysis already relies on; a constructor's address cannot be taken, so it is entered only through such calls

Exemptions.

  • Same-entry pairs are left to entryConcurrency.
  • Callees that start no thread are left out by the relevant fixpoint.
  • Unreachable blocks never run, and a call from one is not a direct call site.
  • A std::thread construction inside a std::thread constructor is the thread the call to that constructor already counts.
  • The rule removed here was the only exemption no guarantee covered.

Channels.

Channel Status
direct starts handled
helpers, before and after their joins handled
an entry starting the other handled
indirect calls, from the initial thread or from a worker, with or without a direct caller handled
a constructor delegating to another handled
recursion refused
unresolved entries refused
exceptions caught in a helper handled
other units not handled, #230
per-round order in loops over-refused, #228

Mutants, each run against the full suite; the macOS fixture was also measured under each one:

Mutant Result
the removed rule restored killed (26)
uncertain pairs kept killed (10)
worker exclusion dropped killed (9)
unvisited worker-reachable check dropped killed (3)
incomplete expansion kept killed (8)
uncontained call taken as completed killed (3)
completion ignored killed (80)
unresolved entry skipped killed (6)
recursion tolerated killed (2)
address-taken functions not seeded killed (6)
outermost frame only killed (3)
delegating constructor counted as a start killed by the macOS fixture (1 race); the Linux suite has no such constructor body
bound on occurrences ignored survives: removing the bound can change the cost and the precision of the proof, without necessarily making its conclusions incorrect, since every pair still passes the same checks over every occurrence; a test would need more than 4096 occurrences
missing entry argument skipped survives: only a call to pthread_create with fewer arguments than its declaration reaches it, which is undefined behaviour
delegation skipped for any start inside a std::thread constructor survives: equivalent with libc++ and libstdc++, where no std::thread constructor calls pthread_create directly (libc++ goes through __libcpp_thread_create, libstdc++ through _M_start_thread in the shared library)

Not covered.

Related

#228, #229 and #230 were found while measuring this change; none is changed by it.

…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.
@SizzleUnrlsd SizzleUnrlsd added the bug Something isn't working label Oct 25, 2026
@SizzleUnrlsd SizzleUnrlsd self-assigned this Oct 25, 2026
… 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
SizzleUnrlsd marked this pull request as ready for review October 26, 2026 03:13
@SizzleUnrlsd
SizzleUnrlsd merged commit bec5c72 into main Oct 26, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Two thread entries ordered once are taken as ordered for every instance

1 participant