The fix passed review. It still crashed in release. The reviewer was never tested.
The reviewer was me. And an AI model. We both approved the same wrong patch.
This is the retrospective. Symptom, root cause, fix. Plus the debugging loop that actually caught it.
A small C++ worker pool returned garbage. Not always. Only with -O2
. Only after a few thousand tasks.
That is the classic undefined behavior profile. The code looks innocent. The compiler does something legal. Your program does something insane.
Here is the minimal shape of the code:
#include <future>
#include <iostream>
#include <vector>
int main() {
std::vector<std::future<int>> results;
for (int i = 0; i < 4; ++i) {
int local = i * 10;
results.push_back(std::async(std::launch::async, [&local] {
return local + 1;
}));
}
for (auto& r : results) {
std::cout << r.get() << '\n';
}
}
Debug build? Fine. Release build? Random numbers. Sometimes a segfault. Sometimes silence.
My first instinct: data race. The queue is shared. Threads are racing. Add a mutex.
std::mutex m;
{
std::lock_guard<std::mutex> lock(m);
results.push_back(std::async(std::launch::async, [&local] {
return local + 1;
}));
}
The patch compiled. The patch passed review. The garbage stayed.
Why did it look right? Because the symptom matched the story. Intermittent. Load-dependent. Multi-threaded. Every checkbox said "race."
Checkboxes lie. Symptoms are not causes.
I asked a free model on MonkeyCode's free server to review the same snippet. MonkeyCode is an open-source AI coding tool. Its current free tier includes model access, a server option, and ten million tokens. (Disclosure: This article was prepared as part of MonkeyCode's product outreach.)
The model agreed with me. "Add synchronization," it said. Same wrong answer, delivered faster.
That is the moment I stopped trusting reviews. My own included. The AI's included.
A review is an opinion. A test is evidence.
I moved the reproducer to a disposable environment. The free server worked for this. Clean tools, clean state, no "works on my machine."
Then I minimized. I stripped the worker pool down to the loop above. It still failed.
Then I ran sanitizers:
g++ -std=c++17 -O2 -fsanitize=address,undefined repro.cpp -o repro
./repro
The output named the exact line:
ERROR: AddressSanitizer: stack-use-after-scope
#0 in main::$_0::operator()() const repro.cpp:9
#1 in std::__invoke_impl ...
stack-use-after-scope
. The lambda captured local
by reference. The local died at the end of each loop iteration. The async task ran after that. Dangling reference. Undefined behavior.
The mutex changed locking. The bug was a lifetime.
Two different layers. Two different fixes. Only one of them mattered.
Capture by value. One word changed.
results.push_back(std::async(std::launch::async, [local] {
return local + 1;
}));
Verification:
g++ -std=c++17 -O2 -fsanitize=address,undefined repro.cpp -o repro
./repro
Sanitizer clean. Release build clean. The reproducer became a regression test.
This approach needs a compiler with sanitizer support. Embedded toolchains often lack it. No sanitizers, no shortcut.
It also needs a reproducible bug. Heisenbugs under TSan need a different loop. So do races inside third-party binaries you cannot rebuild.
And the free server is for disposable experiments. Not production workloads. Not sensitive data. Treat it like a scratch box, not a datacenter.
AI turned developers into reviewers. But who reviews the reviewer? Run the experiment. That is the review.
Next time a patch passes review, run it on a clean box first. The free server is a decent place to start. Ten million tokens and one free server are enough for an honest reproducer.