Catching a leftover std::function copy before it ships
The code that hit QTBUG-151347 never reads the task after moving it, so the usual move checks have nothing to report. This last episode tests what does catch it. A custom clang-tidy query check flags the three bad sites in a small sample and none of the fixed ones. ThreadSanitizer reports the wrong-thread destructor on libc++, but only when that destructor touches state that belongs to another thread.
In short
bugprone-use-after-moveis silent, because nothing uses the task after the move.cppcoreguidelines-rvalue-reference-param-not-movedwarns about the fixed code and pushes you back toward the bug.- A custom query check on moves of
std::functionreports exactly the three by-value sites in the sample. - ThreadSanitizer needs a destructor that touches thread-confined state before it reports anything.
What the stock checks say
The sample has the by-value chain from the second episode and its corrected twin. I ran five move and parameter checks from clang-tidy 23.1.1 on it:
bugprone-use-after-movebugprone-move-forwarding-referenceperformance-unnecessary-value-paramperformance-move-const-argcppcoreguidelines-missing-std-forward
None of them reported the by-value chain.
A sixth check did report the sample. It is cppcoreguidelines-rvalue-reference-param-not-moved, and it warned on both functions of the fixed version. They take an rvalue reference and never call std::move on it. Following that advice would bring the bug back.
A query check for the move
A custom check can match the move itself. The matcher looks for a move constructor or move assignment whose argument is std::move applied to a std::function. Passing the task on to a Task&& parameter is not a move, so the fixed code does not match:
match expr(anyOf(
cxxConstructExpr(hasDeclaration(cxxConstructorDecl(isMoveConstructor())),
hasArgument(0, <std::move of a std::function>)),
cxxOperatorCallExpr(hasOverloadedOperatorName("="),
callee(cxxMethodDecl(isMoveAssignmentOperator())),
hasArgument(1, <std::move of a std::function>))))
The full config is in the examples repository. It needs clang-tidy 22 or later and the --experimental-custom-checks flag. On the sample it reports three lines: the move assignment into the queue, the move inside the first layer, and the caller’s by-value hand-off. The same lines are the ones that fail on libc++.
The check is a heuristic. It flags every move of a std::function, including moves that never cross a thread. Read the result as a list of places to look at.
What ThreadSanitizer sees
A destructor on the wrong thread is not a data race by itself. The demo gives the captured resource a destructor that decrements a counter owned by the worker thread. The worker drops its reference first and then increments the same counter. On libc++ the by-value chain makes the sending thread run the destructor last. ThreadSanitizer reported the race in the destructor on every run I made.
| Hand-off | libc++ (clang trunk) | libstdc++ (GCC 16.1) |
|---|---|---|
| By-value chain | data race reported | no report |
| Exchange at each layer | no report | no report |
A resource whose destructor touches nothing thread-confined produces no report on either library, so a clean ThreadSanitizer run says little about this bug.
The libc++ code behind it
The move constructor of libc++’s internal function holder has three branches. An empty source stays empty. A callable stored on the heap moves by pointer, and the source becomes empty. A callable stored in the inline buffer is copied by a virtual clone call, and the source keeps its copy. The branches are in lines 237 to 247 of the header at commit c14ba73, and the clone is a placement-new copy of the stored callable.
Sources: QTBUG-151347 · libc++ __functional/function.h at c14ba73 · clang-tidy: bugprone-use-after-move · clang-tidy: cppcoreguidelines-rvalue-reference-param-not-moved.