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.
The Symptom
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.
The First Suspect
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.
The AI's Second Opinion
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.
The Turn: Reproduce on a Clean Box
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
Reading the Sanitizer Output
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.
The Real Fix
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
# 11, 21, 31, 41 — stable across 10,000 runs
Sanitizer clean. Release build clean. The reproducer became a regression test.
The Reusable Debugging Loop
- Reproduce on a clean box. Your laptop has too much state.
- Minimize until the code fits one screen. Small code fails loudly.
- Run ASan and UBSan before you blame threads. Lifetime bugs masquerade as races.
- Ask the AI for a second opinion. Then test that opinion like any other.
- Check lifetimes before you add locks. Locks fix contention, not dangling references.
- Keep the reproducer. It is your regression test.
Limitations
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.
The Takeaway
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.
Top comments (1)
The strongest takeaway for me is “the reviewer was never tested.” A review can validate whether a patch looks plausible, but it can't validate compiler behavior, object lifetime, or concurrency assumptions. That’s especially dangerous with C++ because undefined behavior often makes the first explanation that fits the symptoms feel correct.
The ASan/UBSan step is what changes this from opinion to evidence. I’ve seen the same pattern when stabilizing AI-generated code at IT Path Solutions: instead of asking the model for a better fix, make the failure reproducible, minimize it, and let instrumentation challenge the hypothesis. AI should generate hypotheses; sanitizers and reproducible tests should decide which hypothesis survives.